From 478ac47809c0bf055cda6776844b291f63be9449 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 17:58:18 +0100
Subject: [PATCH 01/23] Update packages
Refresh the root and fixture Composer locks and the npm lock, and raise
the WooCommerce floor to 11.3, which is now the newest stable release on
wordpress.org, WP Packages and php-stubs.
The floor moves as the CONTRIBUTING checklist asks, in one commit: the
woocommerce package's extra.requires-wc, the root's wp-plugin/woocommerce
and php-stubs/woocommerce-stubs constraints, the fixtures' stubs
constraints and their WC requires at least and WC tested up to headers,
the README's excerpt of the woocommerce persona, and the rows that pin
the manifest's floor and consumer-a's translated floor notice. Every
cache group MetaTypes::DEFINITIONS names is still used in WooCommerce
11.3.0, order_item_meta as the group WordPress derives from the
order_item meta type.
Otherwise the Composer locks take jetbrains/phpstorm-stubs 2026.3,
laravel/serializable-closure 2.2.0 and roave/security-advisories, and
the npm lock @wordpress/env 12.1.0, @wordpress/scripts 37.1.0 and
@wordpress/e2e-test-utils-playwright 3.2.0, all past the seven-day
cooldown. WordPress's core pins stay, since 7.1.4 and 6.3.14 are their
branches' newest patches.
Assisted-by: Claude Code:claude-opus-5-5
---
composer.json | 4 +-
composer.lock | 49 +-
package-lock.json | 586 ++++++++++--------
packages/woocommerce/README.md | 2 +-
packages/woocommerce/composer.json | 2 +-
tests/Fixtures/consumer-a/acme-consumer-a.php | 4 +-
tests/Fixtures/consumer-a/composer.json | 2 +-
tests/Fixtures/consumer-a/composer.lock | 72 ++-
tests/Fixtures/consumer-b/composer.lock | 53 +-
.../Fixtures/personas/bootstrap/composer.lock | 41 +-
tests/Fixtures/personas/core/composer.lock | 53 +-
.../Fixtures/personas/settings/composer.lock | 41 +-
tests/Fixtures/personas/shared/composer.lock | 41 +-
.../woocommerce/acme-persona-woocommerce.php | 4 +-
.../personas/woocommerce/composer.json | 2 +-
.../personas/woocommerce/composer.lock | 60 +-
.../ConsumerA/ConsumerAOnNetworkTest.php | 2 +-
tests/Unit/PackageManifestsTest.php | 2 +-
18 files changed, 551 insertions(+), 469 deletions(-)
diff --git a/composer.json b/composer.json
index 00830f4..f4ba15f 100644
--- a/composer.json
+++ b/composer.json
@@ -41,10 +41,10 @@
"automattic/jetpack-changelogger": "^7",
"johnpbloch/wordpress-core": "^7.1",
"nikic/php-parser": "^5",
- "php-stubs/woocommerce-stubs": "^11.2",
+ "php-stubs/woocommerce-stubs": "^11.3",
"phpunit/phpunit": "^13",
"wp-plugin/plugin-check": "^2.2",
- "wp-plugin/woocommerce": "^11.2"
+ "wp-plugin/woocommerce": "^11.3"
},
"minimum-stability": "dev",
diff --git a/composer.lock b/composer.lock
index 8e4cbc5..0475c73 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "fb3874ebd4d6f43289e9e2c869f18d23",
+ "content-hash": "3ec2376675c0d84fa7ec6bb5cbb887b5",
"packages": [
{
"name": "ahegyes/wp-framework-bootstrap",
@@ -148,7 +148,7 @@
"dist": {
"type": "path",
"url": "packages/woocommerce",
- "reference": "390b60d89ae0ec911501d74ebd77e7255cafc462"
+ "reference": "824d11020a708f10f57e37ebe7b944bcdcba5c86"
},
"require": {
"ahegyes/wp-framework-settings": "^2.0",
@@ -160,6 +160,7 @@
"dev-trunk": "2.x-dev"
},
"requires-wp": "7.1",
+ "requires-wc": "11.3",
"requires-plugins": [
"woocommerce"
],
@@ -1357,16 +1358,16 @@
},
{
"name": "php-stubs/woocommerce-stubs",
- "version": "v11.2.2",
+ "version": "v11.3.0",
"source": {
"type": "git",
"url": "https://github.com/php-stubs/woocommerce-stubs.git",
- "reference": "d5c232c183c09689d75c177b01bef60e6dd53639"
+ "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639",
- "reference": "d5c232c183c09689d75c177b01bef60e6dd53639",
+ "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c",
+ "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c",
"shasum": ""
},
"require": {
@@ -1395,10 +1396,10 @@
],
"support": {
"issues": "https://github.com/php-stubs/woocommerce-stubs/issues",
- "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2"
+ "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0"
},
- "time": "2026-11-04T21:47:17+00:00",
- "published-time": "2026-11-04T21:48:23+00:00"
+ "time": "2026-11-12T00:45:07+00:00",
+ "published-time": "2026-11-12T00:47:40+00:00"
},
{
"name": "php-stubs/wordpress-stubs",
@@ -2566,12 +2567,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -2653,6 +2654,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -2724,9 +2726,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2936,6 +2938,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -3106,7 +3109,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -3257,7 +3260,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -3734,8 +3737,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -6236,15 +6239,15 @@
},
{
"name": "wp-plugin/woocommerce",
- "version": "11.2.2",
+ "version": "11.3.0",
"source": {
"type": "svn",
"url": "https://plugins.svn.wordpress.org/woocommerce/",
- "reference": "tags/11.2.2"
+ "reference": "tags/11.3.0"
},
"dist": {
"type": "zip",
- "url": "https://downloads.wordpress.org/plugin/woocommerce.11.2.2.zip"
+ "url": "https://downloads.wordpress.org/plugin/woocommerce.11.3.0.zip"
},
"require": {
"composer/installers": "~1.0|~2.0"
@@ -6263,8 +6266,8 @@
"issues": "https://wordpress.org/support/plugin/woocommerce",
"source": "https://plugins.svn.wordpress.org/woocommerce"
},
- "time": "2026-11-04T21:34:49+00:00",
- "published-time": "2026-11-04T21:34:49+00:00"
+ "time": "2026-11-11T16:56:56+00:00",
+ "published-time": "2026-11-11T16:56:56+00:00"
}
],
"aliases": [],
diff --git a/package-lock.json b/package-lock.json
index 1ff8ad7..857b320 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -65,21 +65,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
- "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz",
+ "integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-compilation-targets": "^7.29.7",
- "@babel/helper-module-transforms": "^7.29.7",
- "@babel/helpers": "^7.29.7",
- "@babel/parser": "^7.29.7",
+ "@babel/generator": "^7.29.13",
+ "@babel/helper-compilation-targets": "^7.29.13",
+ "@babel/helper-module-transforms": "^7.29.13",
+ "@babel/helpers": "^7.29.13",
+ "@babel/parser": "^7.29.13",
"@babel/template": "^7.29.7",
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7",
+ "@babel/traverse": "^7.29.13",
+ "@babel/types": "^7.29.11",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -106,13 +106,13 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.29.12",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz",
- "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz",
+ "integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.12",
+ "@babel/parser": "^7.29.13",
"@babel/types": "^7.29.11",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
@@ -136,9 +136,9 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
- "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz",
+ "integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -163,9 +163,9 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
- "version": "7.29.12",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.12.tgz",
- "integrity": "sha512-ACxGoWMV866q/QlZHeCOaQZAR5QclKsIvkAmlDemqjm6+6Bynijs1mH068BzH2N2e+wSuDmBWY87fqKfvhi1Lw==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.13.tgz",
+ "integrity": "sha512-fo8/Fr44/DMV71FQjk/HA0Npmu3bo5DFoWTCW1dhUR8Vx3NFfAVeEy0TbcBpt4e+hd2QhdmWgmspHo/9iWj8RA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -173,8 +173,8 @@
"@babel/helper-member-expression-to-functions": "^7.29.7",
"@babel/helper-optimise-call-expression": "^7.29.7",
"@babel/helper-replace-supers": "^7.29.7",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
- "@babel/traverse": "^7.29.12",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13",
+ "@babel/traverse": "^7.29.13",
"semver": "^6.3.1"
},
"engines": {
@@ -278,15 +278,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
- "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz",
+ "integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
+ "@babel/traverse": "^7.29.13"
},
"engines": {
"node": ">=6.9.0"
@@ -355,23 +355,23 @@
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
- "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.13.tgz",
+ "integrity": "sha512-Abm4rKIH0tmCayJLwcoeOkdzpYUphTbO52fHhyEBHn7S0R+ni4epNLI/8nq1L0DN2Bl0u5qmgWm2Og/z4fBb+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
+ "@babel/traverse": "^7.29.13",
+ "@babel/types": "^7.29.11"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.29.11",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
- "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz",
+ "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -414,23 +414,23 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.29.10",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz",
- "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz",
+ "integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
- "@babel/types": "^7.29.8"
+ "@babel/types": "^7.29.11"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.29.12",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
- "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz",
+ "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -493,14 +493,14 @@
}
},
"node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz",
- "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.13.tgz",
+ "integrity": "sha512-7RpEgFZq9xNRHsWwPLFQpjPOi0ZECE2QZWMBes+vgK7E02krjOfPiYtnHTIUF2nCDyeCJPvSAj0AP4NTVf4hsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13"
},
"engines": {
"node": ">=6.9.0"
@@ -707,9 +707,9 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
- "version": "7.29.12",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.12.tgz",
- "integrity": "sha512-nEreZBofjfd6FG621xe9Rk3+tMGBXf1nAuFrR6NUGF67fkQbSQf3NrbRuhgWRn+zb0qcy9XVok1GVFI/t9wWVQ==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.13.tgz",
+ "integrity": "sha512-q7f5zYLtDLqO11ydQK1w2RqZ1m5kmcAlmgZlfXssKgqVyL8+HgDvWXMxvuEz8TuJfq1UNsY9sB6ABjjtQkN6wA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -795,14 +795,14 @@
}
},
"node_modules/@babel/plugin-transform-destructuring": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz",
- "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.13.tgz",
+ "integrity": "sha512-X2meXB39NOPPmmshSRJzyd50hX9uCzbscNQwVCjLCtq363PZIJFFxJIeZPpgAewC570vpdeGIHTcltwd0QUlJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7",
- "@babel/traverse": "^7.29.7"
+ "@babel/traverse": "^7.29.13"
},
"engines": {
"node": ">=6.9.0"
@@ -927,14 +927,14 @@
}
},
"node_modules/@babel/plugin-transform-for-of": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz",
- "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.13.tgz",
+ "integrity": "sha512-dFjiIw+xBYpgLhYDc1QFQOZmMX44se0YCmXUjPsm9SQEj9HaCJAjvVnK6/yK9iE4E1LaKU5fIAKVJPZWCbKwXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13"
},
"engines": {
"node": ">=6.9.0"
@@ -1812,16 +1812,16 @@
}
},
"node_modules/@babel/traverse": {
- "version": "7.29.12",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz",
- "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==",
+ "version": "7.29.13",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz",
+ "integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.12",
+ "@babel/generator": "^7.29.13",
"@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.12",
+ "@babel/parser": "^7.29.13",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.11",
"debug": "^4.3.1"
@@ -3009,6 +3009,13 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/@isaacs/nonrecursive-glob-match": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
+ "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -5659,15 +5666,15 @@
"license": "MIT"
},
"node_modules/@sentry/bundler-plugins": {
- "version": "11.10.0",
- "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.10.0.tgz",
- "integrity": "sha512-+ngJ485rIYll5uOhWSvSO+Vx4S24MGe5KcLKlUZghr+BXK+ib32uBOZNG294wGyeauLTDvdjncLEYwiaBnP/5w==",
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.11.0.tgz",
+ "integrity": "sha512-et1v8V74ARNOSQ+k4VG7PRiNJl2APGAzlRCiUt42izw8yD+X+E4oCe7wwHjohmv3zufvD1VntHDptSC0/9ChQw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@sentry/core": "11.10.0",
+ "@sentry/core": "11.11.0",
"dotenv": "^17.4.2",
- "glob": "^13.0.6",
+ "glob": "^14.0.3",
"magic-string": "~0.30.8 || ^1.0.0",
"oxc-parser": "^0.152.0",
"sentry": "^0.45.0",
@@ -5706,9 +5713,9 @@
}
},
"node_modules/@sentry/conventions": {
- "version": "0.29.0",
- "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.29.0.tgz",
- "integrity": "sha512-kYq9/HJ/l6MYP/srIc20dy1W4mWyJfCF0OWFF+RhBGZwyr/wCmtabRFKyw7kqM+DTwZXQOjSTSZUcJhDowcInQ==",
+ "version": "0.30.0",
+ "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.30.0.tgz",
+ "integrity": "sha512-WTumd5A2iFmHTRubi6mc6gLHU8LswPQqoNaZu6T1cxiTUmy3WdEw0i8FAnVbcgxTlsumX5oh63/b4B9lY5wAPQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5716,46 +5723,46 @@
}
},
"node_modules/@sentry/core": {
- "version": "11.10.0",
- "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.10.0.tgz",
- "integrity": "sha512-l/OtTo1v+TQgZtHG2Q5N4IX+gFdAE1tZ27K/P21UCzJglWk54TPEm+d9T2lulPqMK06kZcLwDgRAhE2GO4YS5w==",
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.11.0.tgz",
+ "integrity": "sha512-PRrcEdPrTYuHcbDa6DhJZR9Qg2d+2gjKVXiv2QeiTgxvhvXDxYiaVSXH0lldKTR099Er5TAEChiX7aEu4yNlUw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@sentry/conventions": "^0.29.0"
+ "@sentry/conventions": "^0.30.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/node": {
- "version": "11.10.0",
- "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.10.0.tgz",
- "integrity": "sha512-ZsndrSn1NjL9AslpUdxz/kYPcv/i+plVZoyPe1ivlr5XPUpocB8pUL5noYbIUl96Wd/yGvPBw6zqO8d69rRKQQ==",
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.11.0.tgz",
+ "integrity": "sha512-6hbFui4AwP/1KGQD7wgvG0rjve4vgb9x+cBAaHV2N7YwZDZUoKdVaJr/S4PYByuI6UPR8xtwGluFlMFXOScz+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
- "@sentry/bundler-plugins": "11.10.0",
- "@sentry/conventions": "^0.29.0",
- "@sentry/core": "11.10.0",
- "@sentry/opentelemetry": "11.10.0",
- "@sentry/server-runtime-injection": "11.10.0",
- "@sentry/server-utils": "11.10.0"
+ "@sentry/bundler-plugins": "11.11.0",
+ "@sentry/conventions": "^0.30.0",
+ "@sentry/core": "11.11.0",
+ "@sentry/opentelemetry": "11.11.0",
+ "@sentry/server-runtime-injection": "11.11.0",
+ "@sentry/server-utils": "11.11.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/opentelemetry": {
- "version": "11.10.0",
- "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.10.0.tgz",
- "integrity": "sha512-YMV4ZgaRlW7QPCcMScVpSBvYOdvDlY+alqRT3KOTcmPKFZtf8O2hXHWFhl/M33kKDEfqOk2XtEVeHs3mZFOCaw==",
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.11.0.tgz",
+ "integrity": "sha512-L1lXAF1hNUSOTLG8NLkBaopvWWpXqlYgQ++cfWr8Gw4wdmReNp9ec533bEj4r6ZJ6PvIYNdIVNI22GdvmbP6xg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@sentry/conventions": "^0.29.0",
- "@sentry/core": "11.10.0"
+ "@sentry/conventions": "^0.30.0",
+ "@sentry/core": "11.11.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
@@ -5765,29 +5772,29 @@
}
},
"node_modules/@sentry/server-runtime-injection": {
- "version": "11.10.0",
- "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.10.0.tgz",
- "integrity": "sha512-1nc+HNPldrPEov16sL5m2cOLVgNJ+FsR7QtaWGOOv8qYFJkSp95lyc2qzLnaG2L72UzSMOv10peGRioAKrrZQQ==",
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.11.0.tgz",
+ "integrity": "sha512-nHHWfOYi2sB7db9Hjr3i/H66e1/YK9WMlBx3MSI6p/Ha66BGHF3vea/+gILjPz21viQclg1xq37mj8k9KLkGJw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@sentry/core": "11.10.0",
- "@sentry/server-utils": "11.10.0"
+ "@sentry/core": "11.11.0",
+ "@sentry/server-utils": "11.11.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/server-utils": {
- "version": "11.10.0",
- "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.10.0.tgz",
- "integrity": "sha512-m/MVCvPzQJJZZCTFlVPCrTuoncT7SR80+QKg1K7ul25AsL0byABr5KsHCbXVpOYXLVz04Omk+pOOR5Cv2IAlrA==",
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.11.0.tgz",
+ "integrity": "sha512-t1lx9Zw+zhRre0ko+F3CsucG2BqB/7KOZmG1W+Wv/fSvYSnT06vg7WhIvmvH+df7BSFO+cFGBpkxwQWGq3zj+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
- "@sentry/conventions": "^0.29.0",
- "@sentry/core": "11.10.0"
+ "@sentry/conventions": "^0.30.0",
+ "@sentry/core": "11.11.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
@@ -7406,9 +7413,9 @@
}
},
"node_modules/@wordpress/babel-preset-default": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.0.0.tgz",
- "integrity": "sha512-mXxwcIfEuj10SnofavV13/0NshtkrI+d1S/vSWrtgU5ibj+ejcvAXBiqIPBsb3RZDZwipTbRJV+VbyBjaf4IrQ==",
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.1.0.tgz",
+ "integrity": "sha512-D59dkRTnhgAoJekTXlXp2oPbUwmiVOC/kiyu6aEh/IJUwK1G9WEpbsvvgsVIWZTJTxGsdGKy3MdcZPW6nKS/FA==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -7418,8 +7425,8 @@
"@babel/plugin-transform-runtime": "^7.25.7",
"@babel/preset-env": "^7.25.7",
"@babel/preset-typescript": "^7.25.7",
- "@wordpress/browserslist-config": "^7.0.0",
- "@wordpress/warning": "^4.0.0",
+ "@wordpress/browserslist-config": "^7.1.0",
+ "@wordpress/warning": "^4.1.0",
"browserslist": "^4.28.7",
"core-js": "^3.31.0",
"react": "^19.3.0"
@@ -7429,9 +7436,9 @@
}
},
"node_modules/@wordpress/browserslist-config": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.0.0.tgz",
- "integrity": "sha512-eZ2yaOBprRNppFNN/qubJ7C2nOX6llR+IeQ5jQC1665sFcOkZes+hLwpbKsZNcmL26ge+77vdQ0f/Ur67rgkgw==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.1.0.tgz",
+ "integrity": "sha512-4vPRj28I2wTiGhUoVU9Kirjed+1eurphEjGpf626FlIU0f9sExLMuTWYLjqgU9aVvxkmm1M+CKdrNL0GnSnv9g==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -7439,21 +7446,21 @@
}
},
"node_modules/@wordpress/compose": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.0.0.tgz",
- "integrity": "sha512-xP7EDOTyJrc5fC48Zh/u9Hvu+eHvKlU+qn5OyDQi1ahmg5GqNq3UAaODnsgeLyggGgWtmJhbzgN0qIJvhn52Sw==",
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.1.0.tgz",
+ "integrity": "sha512-L3YoJWyIgQs50Y4A9qdptjDWdfWjFr9V3TqZIzR/yehBA86UwlmP5iBhM8epQTylDf/RsTttJBm8f37/wQvStw==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
"@types/mousetrap": "^1.6.8",
- "@wordpress/deprecated": "^5.0.0",
- "@wordpress/dom": "^5.0.0",
- "@wordpress/element": "^10.0.0",
- "@wordpress/is-shallow-equal": "^6.0.0",
- "@wordpress/keycodes": "^5.0.0",
- "@wordpress/priority-queue": "^4.0.0",
- "@wordpress/private-apis": "^3.0.0",
- "@wordpress/undo-manager": "^2.0.0",
+ "@wordpress/deprecated": "^5.1.0",
+ "@wordpress/dom": "^5.1.0",
+ "@wordpress/element": "^10.1.0",
+ "@wordpress/is-shallow-equal": "^6.1.0",
+ "@wordpress/keycodes": "^5.1.0",
+ "@wordpress/priority-queue": "^4.1.0",
+ "@wordpress/private-apis": "^3.1.0",
+ "@wordpress/undo-manager": "^2.1.0",
"change-case": "^4.1.2",
"mousetrap": "^1.6.5"
},
@@ -7476,9 +7483,9 @@
}
},
"node_modules/@wordpress/dependency-extraction-webpack-plugin": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.0.0.tgz",
- "integrity": "sha512-SYzb/CWt8VfGGMwGb7UVjBV8qGBkY6PqybUWFtn581lx/KMJANwswLPTWA/QOhog0NSDynscpQuOPibQ5ULceQ==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.1.0.tgz",
+ "integrity": "sha512-9oFBFJJnJLQl6izhyXDQfFpHUrCNNZ3YUC83AtmwcMpi0AjesuEZ5OYX7SdMof6vRSOG+6UXIvs61Z8S5NswjA==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -7492,35 +7499,35 @@
}
},
"node_modules/@wordpress/deprecated": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.0.0.tgz",
- "integrity": "sha512-PZjrzeX15ZNIDvcBzOXnXF/FpKNwZgz23hQ+xpcmRBjZKCyT6YoxC45O3jFIGyZZtBwxoxCYQPBkCjRuKbVcpg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.1.0.tgz",
+ "integrity": "sha512-y56h8AeOVDxXylvVqZRtDjEPO5Z3Kp2ku6XuCnb0j+LeJfTnPhkPN/G1dh07S87b1BzwvI4AYeJa8ZpH/N1eRw==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/hooks": "^5.0.0"
+ "@wordpress/hooks": "^5.1.0"
},
"engines": {
"node": ">=22.13.0"
}
},
"node_modules/@wordpress/dom": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.0.0.tgz",
- "integrity": "sha512-jKRFuQgNuAbFhiIU/b1xQl00rEphMdBQal6pmiN/pDVfIR1idZFaaI8K+eH/Gqn+yb/q20YIqPLM1+SXR4xy8w==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.1.0.tgz",
+ "integrity": "sha512-KAul2g72Krm3JWKloiKYdlIsk9uFQYY8FpL2dFFyniSy7kUXo8yVG70YnxSeQfOG1mMWXGog8N6h6OkTTvG8nQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/deprecated": "^5.0.0"
+ "@wordpress/deprecated": "^5.1.0"
},
"engines": {
"node": ">=22.13.0"
}
},
"node_modules/@wordpress/e2e-test-utils-playwright": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.1.0.tgz",
- "integrity": "sha512-ybc60l3g1syfpVJ7L/zOculIYBsfVfwsWOAn/bm4vN4+irRT6jJ/11bbPhclb9yjDyGatn+aHw89OhhHLjz8mQ==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.2.0.tgz",
+ "integrity": "sha512-U7SoahoL8V3UUngjeSYHeKNnQb6EvF70hEzl9rV+Qap6exbNCywo11K0AU7IK0JWgAK5uqVCnC9TVEuYmamdrg==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -7544,13 +7551,13 @@
}
},
"node_modules/@wordpress/element": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.0.0.tgz",
- "integrity": "sha512-gv3cZ3O2j4y7qUUBo/h1Ni9OxG6ak78+D0YhggMsGyIPavT4CpJtRyMbarhENJ7K1MM9JzpuecdlMID8Cfd51Q==",
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.1.0.tgz",
+ "integrity": "sha512-MfHroJjXMcbPZvs8xzewbRJeW4gLnwzI856PqY4SrBnEStkRwzpSbLJW0HEsHaOK1d+Wnu4Hm60amuLLe6WdsQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/escape-html": "^4.0.0",
+ "@wordpress/escape-html": "^4.1.0",
"change-case": "^4.1.2",
"is-plain-object": "^5.1.0"
},
@@ -7573,9 +7580,9 @@
}
},
"node_modules/@wordpress/env": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.0.0.tgz",
- "integrity": "sha512-RoUQV3sl1qF4h/kg+vrec9PCfr2bwXSOmWoLd9NmV8rD/sCBMt7tSBfeTWZEbClMhWHce8tu4hJ+pQeHXNnu2A==",
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.1.0.tgz",
+ "integrity": "sha512-wLodozbP19ON/yV+wDmf8yxbq9NOiacKcaKkLM1wF5c9pO516tTHn5kPrMItyI6B6m/9oULPiywlBUjzfqvF7A==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -7601,9 +7608,9 @@
}
},
"node_modules/@wordpress/escape-html": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.0.0.tgz",
- "integrity": "sha512-fWEvTJvNyPUSAXz3pMETrt1DHKaQ9CJVirhuBqb1xuZmIdRY4AlsjLgayd+lUSTkTU+0gRIPu1TDdU5/6Ue2Nw==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.1.0.tgz",
+ "integrity": "sha512-k9lDVTRi2vbTKLyQJDVxNwthTqAmsNSxTVJurDGAdZfXXvbR5QrASTNXXK/sfofqf2wWDFAgI567rhKiAKiYmw==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -7611,16 +7618,16 @@
}
},
"node_modules/@wordpress/eslint-plugin": {
- "version": "28.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.0.0.tgz",
- "integrity": "sha512-VRFrez67J9HWGR4s149A3ZG1ynY9uEczmuR7SFXzlcUn3itlo5ood42pv1dgTZK3eqJo3l5tdK3hD94W+ZEq8w==",
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.1.0.tgz",
+ "integrity": "sha512-u2yK55Bx/ycVn6ZSYim73pxE2gscNVUGF22amHD6sY/vGU9SpQPg0By0WJPmPtXsMBNDhNEYPmDN95MKTxb8bg==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
"@eslint/compat": "^2.0.0",
"@vitest/eslint-plugin": "^1.6.27",
- "@wordpress/prettier-config": "^5.0.0",
- "@wordpress/theme": "^3.0.0",
+ "@wordpress/prettier-config": "^5.1.0",
+ "@wordpress/theme": "^4.0.0",
"cosmiconfig": "^7.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -7654,9 +7661,9 @@
}
},
"node_modules/@wordpress/hooks": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.0.0.tgz",
- "integrity": "sha512-jA2hDMNr2Ky/D14s8K+TQ/ZI125icjZfTPZYcsgEHM6mTtSJY2dhcmp9+tfSrcnY+M17svzplIxXma+bpBzcFg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.1.0.tgz",
+ "integrity": "sha512-EvHdv0beYOcINcY91jj+brsoLu87AEp2k7gzJC/Xdux3Ng0WLk2VL9A66vpsyZ61J1/Uiivmo3zR/LhC3Y0UzA==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -7664,14 +7671,14 @@
}
},
"node_modules/@wordpress/i18n": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.0.0.tgz",
- "integrity": "sha512-xk7qKLDB08c3LDUtThfmuumq//354bXLGRrPWfC1gmwPMNJPRC7ggwtSFxzH+ImdssxIKk8NaG4H3yF5WJoS5w==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.1.0.tgz",
+ "integrity": "sha512-WCwJZLUlyI5rbdCBdBDxPGgD405JjiQPzyHRcqS2FwZZDb1+dFnMuDBgeewzWKEtsXlOLJnrJQWKggNJVXnOKQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@tannin/sprintf": "^1.3.2",
- "@wordpress/hooks": "^5.0.0",
+ "@tannin/sprintf": "^1.3.4",
+ "@wordpress/hooks": "^5.1.0",
"gettext-parser": "^1.3.1",
"tannin": "^1.2.0"
},
@@ -7683,9 +7690,9 @@
}
},
"node_modules/@wordpress/is-shallow-equal": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.0.0.tgz",
- "integrity": "sha512-KMEZTCaXWp5udPNdPc8lEky3AEhKnUej0aKfjV38Ggox2e87SDXALt3DdMhydO//2xlXZXfGtYtBJ0qk9LpnLQ==",
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.1.0.tgz",
+ "integrity": "sha512-3zZw6SnVLPCfxHxHo7nlg6lRzKn7te/h4al2kdZ3GMWUAdtRsiJZKgiJcVCqNlJN7tbOyNXUepbVXyGJhrTE3Q==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -7693,13 +7700,13 @@
}
},
"node_modules/@wordpress/keycodes": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.0.0.tgz",
- "integrity": "sha512-15T931kJjj90KlDcsXKaGucK+iyqY9CftR7tXO60m8T8gDC2GVuUWMIX3Sq9+4fsahABuqoUd+QTiW4xSDlpCg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.1.0.tgz",
+ "integrity": "sha512-bqelkvytmYitRbE/3LvMw2xHHFVgq+WYHfv6CiQ+O2HwOES45fuPYhCw6r05yZO5zEg+nh0dB0X4bfeR8kwAsw==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/i18n": "^7.0.0"
+ "@wordpress/i18n": "^7.1.0"
},
"engines": {
"node": ">=22.13.0"
@@ -7714,9 +7721,9 @@
}
},
"node_modules/@wordpress/npm-package-json-lint-config": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.0.0.tgz",
- "integrity": "sha512-h0VN1RW3OqsreiFrJ13FD/jFX9b+VHerfWNED/OUUSi17pmVgik47niTfth1/xpotL+kz2RuBopb9oV+HpKcQQ==",
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.1.0.tgz",
+ "integrity": "sha512-72fpK1zMW3X9xSarhweHqoBOiIYJD4FWGTkjrTYxUWF3DvR0wmKNqbL2mpzL7aD7tCBVEPN8tKhhDAqdnnS26g==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -7727,13 +7734,13 @@
}
},
"node_modules/@wordpress/postcss-plugins-preset": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.0.0.tgz",
- "integrity": "sha512-Z8PLYiY32e0k7xQz0Fc7K30PcOIFvaoc9JduPMQMCfmukJKFMaRYxqyJZNdva7fPu18vUl9dFpB6ZP/+C5aZYQ==",
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.1.0.tgz",
+ "integrity": "sha512-oOGrqTX0v+M44V/nliAS0OUr1Mmtn6TfMBO9zll0DP7/+CdPyEdRcNoTjicXWhd/DnYfAsetcBBtSBaBSLJTMg==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/browserslist-config": "^7.0.0",
+ "@wordpress/browserslist-config": "^7.1.0",
"autoprefixer": "^10.4.21",
"postcss-import": "^16.1.1"
},
@@ -7745,9 +7752,9 @@
}
},
"node_modules/@wordpress/prettier-config": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.0.0.tgz",
- "integrity": "sha512-uQ3rtrqt8yvUHFN9Xcxuw7EfSw4ArMkwFxtz2RHZM2Sis9jzB7xk0FcK/iWE55q0WWSjmFGWoNVpOEQoSE7ocQ==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.1.0.tgz",
+ "integrity": "sha512-Ep67YhwkEVAO2i+7lbzNqQrvUlSFY1oecth7WehXvp2jq0X2X/aDNEMOPxbY0sf6KKmYaWrRiu5ECOqB2QgJ7w==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -7758,9 +7765,9 @@
}
},
"node_modules/@wordpress/priority-queue": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.0.0.tgz",
- "integrity": "sha512-YXEwn7xtr4bUts2IOPREjlWYa3n/ba7ndLCqFN3ofeRvvr2ZVCI1WP20v7a+CQZi+n9FS7fy+lYqx3wnTYtGzg==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.1.0.tgz",
+ "integrity": "sha512-Xt9O99LLh3YT5LVXsxsGrCJDOuGCv61qK6dWZLitCIg/BxCPc/ihfV98lm4Fupye57439vqPIHKpjV3vJEmUBg==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -7771,9 +7778,9 @@
}
},
"node_modules/@wordpress/private-apis": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.0.0.tgz",
- "integrity": "sha512-IDXGi2VWfjUFwBTDtWj7/FMY240JpqraxFEIaXIeD3KFumX8lQuAYvE9gU8GcNBt/NlXZCDEmNioxCbAZN+jiA==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.1.0.tgz",
+ "integrity": "sha512-Zj9LAP8+Z+2tihAEPNniS6+t0oYSEEbkQ4uHtvzvZpaD+UM9EAmThpMPKR4YP9l4XMb/Y4W/fiMSNm1mDnPY1w==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -7781,24 +7788,24 @@
}
},
"node_modules/@wordpress/scripts": {
- "version": "37.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.0.0.tgz",
- "integrity": "sha512-/a01kTH8lqAPzO6AhZRBiFpWcDorZs32U07YdkNhV7UOQAhWrBAWBOXPsLBcCh7NRt603C/hYOea8YcIqPpbZA==",
+ "version": "37.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.1.0.tgz",
+ "integrity": "sha512-75ZJTN+uAp9ESxS43hbQjYJRjHFUb0gTF1SQ53iDsX5MxjDjQRzdnNpNMw5peYz5Sv4WE7/l3Hfg3WkVm5U3sg==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
"@babel/core": "^7.25.7",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.3",
"@svgr/webpack": "^8.0.1",
- "@wordpress/babel-preset-default": "^10.0.0",
- "@wordpress/browserslist-config": "^7.0.0",
- "@wordpress/dependency-extraction-webpack-plugin": "^7.0.0",
- "@wordpress/e2e-test-utils-playwright": "^3.1.0",
- "@wordpress/eslint-plugin": "^28.0.0",
- "@wordpress/npm-package-json-lint-config": "^6.0.0",
- "@wordpress/postcss-plugins-preset": "^6.0.0",
- "@wordpress/prettier-config": "^5.0.0",
- "@wordpress/stylelint-config": "^27.0.0",
+ "@wordpress/babel-preset-default": "^10.1.0",
+ "@wordpress/browserslist-config": "^7.1.0",
+ "@wordpress/dependency-extraction-webpack-plugin": "^7.1.0",
+ "@wordpress/e2e-test-utils-playwright": "^3.2.0",
+ "@wordpress/eslint-plugin": "^28.1.0",
+ "@wordpress/npm-package-json-lint-config": "^6.1.0",
+ "@wordpress/postcss-plugins-preset": "^6.1.0",
+ "@wordpress/prettier-config": "^5.1.0",
+ "@wordpress/stylelint-config": "^27.1.0",
"adm-zip": "^0.6.1",
"babel-loader": "^9.2.1",
"browserslist": "^4.28.7",
@@ -8078,9 +8085,9 @@
"license": "MIT"
},
"node_modules/@wordpress/style-runtime": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.14.0.tgz",
- "integrity": "sha512-cWk3G7Pe6Wo9uMpaPAbrhsPJAmkShPn3LLc8vkte1ZQ7ls1M+e6m78KQxd0HYy4ty2nbGGlhnHE7ISxQzGhrsQ==",
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.15.0.tgz",
+ "integrity": "sha512-Rc8D0DK17I7OfG1KbMVw3E8SC+84D4LRMnHq0eHbNwilBTogGiieo0MWx1j6Bzkf82Q5n5JCPct+zm0W0Gk84A==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -8088,14 +8095,14 @@
}
},
"node_modules/@wordpress/stylelint-config": {
- "version": "27.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.0.0.tgz",
- "integrity": "sha512-NvLTijt+fZR2/0T3EwH5cswLdpuo/On1B+BQlkkuQ3HsNrwWLTEvwboaOqzJUwFs/owB9+AO11Ocaayexesalw==",
+ "version": "27.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.1.0.tgz",
+ "integrity": "sha512-AAV9GMDyRCnHKfmntb8C40u1zSDIkV1HotHMD3CK0Om+VX1aYVjkdRJikSzvcYEcaS6zK8KaKpDFlSvvNbdcXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@stylistic/stylelint-plugin": "^5.2.1",
- "@wordpress/theme": "^3.0.0",
+ "@wordpress/theme-build-tools": "^1.0.0",
"stylelint-config-recommended": "^18.0.0",
"stylelint-config-recommended-scss": "^17.0.1"
},
@@ -8103,14 +8110,55 @@
"node": ">=22.13.0"
},
"peerDependencies": {
+ "@wordpress/theme": "^3.0.0 || ^4.0.0",
"stylelint": "^17.14.1",
"stylelint-scss": "^7.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@wordpress/theme": {
+ "optional": true
+ }
}
},
"node_modules/@wordpress/theme": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-3.0.0.tgz",
- "integrity": "sha512-m25ORtLD77WL40vG3Vt/0WKzg/liuiM2PGMNaDv4SDO1hZ7H0t+Lp8rfTlsYnBdI+iO8+juICyK4OiVRde1I0Q==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-4.0.0.tgz",
+ "integrity": "sha512-VKlIu/ulNrJjeNvWw50HD3x2wKq/ntGQnir5ep0r5DOjpdYYEV0/oGXQSQBARcd4H6TncKX5NzaGL0aBYQq7sg==",
+ "dev": true,
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/compose": "^9.1.0",
+ "@wordpress/deprecated": "^5.1.0",
+ "@wordpress/element": "^10.1.0",
+ "@wordpress/private-apis": "^3.1.0",
+ "@wordpress/style-runtime": "^0.15.0",
+ "colorjs.io": "^0.7.1",
+ "memize": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=22.13.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18 || ^19",
+ "react": "^18 || ^19",
+ "react-dom": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wordpress/theme-build-tools": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/theme-build-tools/-/theme-build-tools-1.0.0.tgz",
+ "integrity": "sha512-4oy+LYzAokRTentySXjyza7RCMUJG8Ul6mLNTOC+hcwCdwo7yzR3SMhyJfe4uFPON1CNk+s2claCmiIHZnRSPQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
@@ -8118,30 +8166,21 @@
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"@jridgewell/trace-mapping": "^0.3.31",
- "@wordpress/compose": "^9.0.0",
- "@wordpress/deprecated": "^5.0.0",
- "@wordpress/element": "^10.0.0",
- "@wordpress/private-apis": "^3.0.0",
- "@wordpress/style-runtime": "^0.14.0",
- "colorjs.io": "^0.7.1",
- "magic-string": "^0.30.21",
- "memize": "^2.1.1"
+ "magic-string": "^0.30.21"
},
"engines": {
"node": ">=22.13.0"
},
"peerDependencies": {
- "@types/react": "^18 || ^19",
+ "@wordpress/theme": "^3.0.0 || ^4.0.0",
"esbuild": ">=0.27.2 <1.0.0",
"lightningcss": ">=1.33.0 <2.0.0",
"postcss": "^8.0.0",
- "react": "^18 || ^19",
- "react-dom": "^18 || ^19",
"stylelint": "^16 || ^17",
"vite": "^7 || ^8"
},
"peerDependenciesMeta": {
- "@types/react": {
+ "@wordpress/theme": {
"optional": true
},
"esbuild": {
@@ -8153,12 +8192,6 @@
"postcss": {
"optional": true
},
- "react": {
- "optional": true
- },
- "react-dom": {
- "optional": true
- },
"stylelint": {
"optional": true
},
@@ -8168,22 +8201,22 @@
}
},
"node_modules/@wordpress/undo-manager": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.0.0.tgz",
- "integrity": "sha512-OQfGPUBz79/NKkkBvF5L/k1DmxuCQGYvczA/BOef+ECCCnjpIXF6HS1txpULozI5owejQDDjhZp25bq25jF5Vg==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.1.0.tgz",
+ "integrity": "sha512-n4c6/9V51sNzche2wJReKVUm7c/9ChZsg52939FJuITcLBp5MnR+oEeSe2RQPP4Mq1x3EQAFuM2HibaEnliqvQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"dependencies": {
- "@wordpress/is-shallow-equal": "^6.0.0"
+ "@wordpress/is-shallow-equal": "^6.1.0"
},
"engines": {
"node": ">=22.13.0"
}
},
"node_modules/@wordpress/warning": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.0.0.tgz",
- "integrity": "sha512-qhAwfjMUYT1TfyMlrkO4i1IbcqfjfjLBFepXSRvMUhNbL8CRRu3cgjdZNCf82FkvUwmuCEuMsrmGisxu4CGr1A==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.1.0.tgz",
+ "integrity": "sha512-nKzIGM+H4YIaGIQY4+KTi6pSRWC4rVm0SKz3TUbORp5WXXq18MsiUqvG4D0vq/fuzo5fhGC5/Ee/T+EPDQF9FQ==",
"dev": true,
"license": "GPL-2.0-or-later",
"engines": {
@@ -9515,9 +9548,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001820",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz",
- "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==",
+ "version": "1.0.30001822",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001822.tgz",
+ "integrity": "sha512-Y3fvWQWzc69dlCyr07tuNrEhotv+db5FpMSPD3slZnkPKwRNOKwFBz2dP966XUmWMXLTOt+YShkFoQoom3U7hg==",
"dev": true,
"funding": [
{
@@ -11078,9 +11111,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.473",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.473.tgz",
- "integrity": "sha512-9njcRQK+6FOXNAWw5g+h2RPiyazqo5RFMVJ39ZMHjDHUNnnFlhnOGV7O0WmJetODDx19ES5p+FLi+KCVFQGugw==",
+ "version": "1.5.477",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.477.tgz",
+ "integrity": "sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==",
"dev": true,
"license": "ISC"
},
@@ -12503,9 +12536,9 @@
}
},
"node_modules/fast-xml-parser": {
- "version": "5.11.2",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
- "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
+ "version": "5.12.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.12.0.tgz",
+ "integrity": "sha512-/pXxRBtwxY9w8z7YbgsvIPPcPvFDsTbIkWwTQJhzNJPokj+TbN6ftmcv4j/pEQDvOCNnKGDzuk5Bo2oogIOiFg==",
"dev": true,
"funding": [
{
@@ -12515,11 +12548,11 @@
],
"license": "MIT",
"dependencies": {
- "@nodable/entities": "^3.0.1",
- "fast-xml-builder": "^1.2.0",
- "is-unsafe": "^2.0.0",
+ "@nodable/entities": "^3.1.0",
+ "fast-xml-builder": "^1.3.1",
+ "is-unsafe": "^2.0.3",
"path-expression-matcher": "^1.6.2",
- "strnum": "^2.4.2",
+ "strnum": "^2.5.1",
"xml-naming": "^0.3.0"
},
"bin": {
@@ -12984,15 +13017,15 @@
}
},
"node_modules/glob": {
- "version": "13.0.6",
- "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
- "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz",
+ "integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
- "minimatch": "^10.2.2",
+ "minimatch": "^11.0.2",
"minipass": "^7.1.3",
- "path-scurry": "^2.0.2"
+ "path-scurry": "^3.0.0"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -13031,6 +13064,23 @@
"tslib": "2"
}
},
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "11.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
+ "integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/nonrecursive-glob-match": "^1.0.3",
+ "brace-expansion": "^5.0.12"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/global-modules": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
@@ -13991,9 +14041,9 @@
}
},
"node_modules/is-core-module": {
- "version": "2.17.0",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz",
- "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==",
+ "version": "2.18.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.18.0.tgz",
+ "integrity": "sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15291,13 +15341,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.truncate": {
- "version": "4.4.2",
- "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
- "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -17674,9 +17717,9 @@
"license": "MIT"
},
"node_modules/path-scurry": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
- "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz",
+ "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -19268,16 +19311,16 @@
}
},
"node_modules/regexpu-core": {
- "version": "6.5.3",
- "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.3.tgz",
- "integrity": "sha512-E8VgUoSK+LMQrLboDxavm76MTBGrQhx9RiNuomRmYnZ10Uw12HrI8fkxOOncktb2hBpzIZ9SV/347euQHG5z+g==",
+ "version": "6.5.4",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.4.tgz",
+ "integrity": "sha512-VpdamrZ9bw4YmvMZfTA3Yg8IHU9Guvg21bonvgjC4l3KfBkOqCPJesHakz7cQeE4Vh4zz4r4Gy6igAcnJxZv9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"regenerate": "^1.5.0",
"regenerate-unicode-properties": "^10.3.0",
"regjsgen": "^0.8.0",
- "regjsparser": "^0.13.0",
+ "regjsparser": "^0.14.0",
"unicode-match-property-ecmascript": "^2.0.0",
"unicode-match-property-value-ecmascript": "^2.2.1"
},
@@ -19293,9 +19336,9 @@
"license": "MIT"
},
"node_modules/regjsparser": {
- "version": "0.13.4",
- "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.4.tgz",
- "integrity": "sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA==",
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.14.1.tgz",
+ "integrity": "sha512-IeYboCLG1KBNRksrZQIZ1SV5KVWZiR0ikFokOcA0n63vLvlaFvCsfxN+fd4aXwL0KW3YD+ByRzIgo9ZAHdbldw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -19356,14 +19399,14 @@
}
},
"node_modules/resolve": {
- "version": "1.22.13",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz",
- "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==",
+ "version": "1.22.14",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.14.tgz",
+ "integrity": "sha512-1ZjYvR9sbkiwC5+9QHetqW5HmA2pHRMaoupD01t2HlYnV/vydC2zPwLixhsMieSWVZt0JJRg5LVIk0Ow1D/TrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "is-core-module": "^2.17.0",
+ "is-core-module": "^2.18.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
@@ -21457,14 +21500,13 @@
}
},
"node_modules/table": {
- "version": "6.9.0",
- "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz",
- "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==",
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/table/-/table-6.10.1.tgz",
+ "integrity": "sha512-f05boz6UD5S70AJiUxqKOnDuE45mBIVagX490oQmcW38+Lq/H1s7CjIpFgJjR/kpV87L4lw/AN+14D2PktQJsQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"ajv": "^8.0.1",
- "lodash.truncate": "^4.4.2",
"slice-ansi": "^4.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1"
@@ -21699,9 +21741,9 @@
}
},
"node_modules/tldts-core": {
- "version": "7.4.20",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
- "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
+ "version": "7.4.19",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz",
+ "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==",
"dev": true,
"license": "MIT"
},
diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md
index 34883bc..bd0579a 100644
--- a/packages/woocommerce/README.md
+++ b/packages/woocommerce/README.md
@@ -40,7 +40,7 @@ WordPress loads every plugin into one PHP process, so each plugin ships the fram
"require-dev": {
"ahegyes/wordpress-configs": "dev-trunk",
"humbug/php-scoper": "^0.18",
- "php-stubs/woocommerce-stubs": "^11.2"
+ "php-stubs/woocommerce-stubs": "^11.3"
},
"repositories": [
{ "type": "vcs", "url": "https://github.com/ahegyes/wordpress-configs" }
diff --git a/packages/woocommerce/composer.json b/packages/woocommerce/composer.json
index 5b40f0a..5755925 100644
--- a/packages/woocommerce/composer.json
+++ b/packages/woocommerce/composer.json
@@ -18,7 +18,7 @@
"dev-trunk": "2.x-dev"
},
"requires-wp": "7.1",
- "requires-wc": "11.2",
+ "requires-wc": "11.3",
"requires-plugins": [
"woocommerce"
],
diff --git a/tests/Fixtures/consumer-a/acme-consumer-a.php b/tests/Fixtures/consumer-a/acme-consumer-a.php
index 7e56273..8a2ca26 100644
--- a/tests/Fixtures/consumer-a/acme-consumer-a.php
+++ b/tests/Fixtures/consumer-a/acme-consumer-a.php
@@ -8,8 +8,8 @@
* Requires at least: 7.1
* Requires PHP: 8.5
* Requires Plugins: woocommerce
- * WC requires at least: 11.2
- * WC tested up to: 11.2
+ * WC requires at least: 11.3
+ * WC tested up to: 11.3
* Author: Acme
* License: GPL-3.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
diff --git a/tests/Fixtures/consumer-a/composer.json b/tests/Fixtures/consumer-a/composer.json
index a1bf3eb..d92e68c 100644
--- a/tests/Fixtures/consumer-a/composer.json
+++ b/tests/Fixtures/consumer-a/composer.json
@@ -29,7 +29,7 @@
"humbug/php-scoper": "^0.18",
"laravel/serializable-closure": "^2.1",
"php-di/php-di": "^7.1.1",
- "php-stubs/woocommerce-stubs": "^11.2"
+ "php-stubs/woocommerce-stubs": "^11.3"
},
"minimum-stability": "dev",
diff --git a/tests/Fixtures/consumer-a/composer.lock b/tests/Fixtures/consumer-a/composer.lock
index e3c74ed..7cba8f6 100644
--- a/tests/Fixtures/consumer-a/composer.lock
+++ b/tests/Fixtures/consumer-a/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "e29b2a1cf0d3437600a2a83b68489d84",
+ "content-hash": "5d45157ba16e981b18750714208c7cba",
"packages": [],
"packages-dev": [
{
@@ -290,7 +290,7 @@
"dist": {
"type": "path",
"url": "../../../packages/woocommerce",
- "reference": "79d6aea22490f658088fcea374c11a136074961f"
+ "reference": "7aa4faa5c982e5ba2e2ed72bdf6255e8a6cf79e9"
},
"require": {
"ahegyes/wp-framework-settings": "^2.0",
@@ -302,6 +302,7 @@
"dev-trunk": "2.x-dev"
},
"requires-wp": "7.1",
+ "requires-wc": "11.3",
"requires-plugins": [
"woocommerce"
],
@@ -812,23 +813,23 @@
},
{
"name": "jetbrains/phpstorm-stubs",
- "version": "v2026.2",
+ "version": "v2026.3",
"source": {
"type": "git",
- "url": "https://github.com/JetBrains/phpstorm-stubs",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9"
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
- "phpdocumentor/reflection-docblock": "^5.6",
- "phpunit/phpunit": "^12.3"
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpunit/phpunit": "^13.2"
},
"type": "library",
"autoload": {
@@ -852,8 +853,11 @@
"stubs",
"type"
],
- "time": "2026-06-12T13:19:10+00:00",
- "published-time": "2026-07-22T11:41:13+00:00"
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3"
+ },
+ "time": "2026-10-19T10:54:17+00:00",
+ "published-time": "2026-11-11T13:34:29+00:00"
},
{
"name": "johnbillion/wp-compat",
@@ -933,16 +937,16 @@
},
{
"name": "laravel/serializable-closure",
- "version": "v2.1.0",
+ "version": "v2.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
"shasum": ""
},
"require": {
@@ -990,8 +994,8 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
- "time": "2026-09-22T14:32:34+00:00",
- "published-time": "2026-09-22T14:33:13+00:00"
+ "time": "2026-10-14T21:20:29+00:00",
+ "published-time": "2026-11-10T16:11:08+00:00"
},
{
"name": "nikic/php-parser",
@@ -1183,16 +1187,16 @@
},
{
"name": "php-stubs/woocommerce-stubs",
- "version": "v11.2.2",
+ "version": "v11.3.0",
"source": {
"type": "git",
"url": "https://github.com/php-stubs/woocommerce-stubs.git",
- "reference": "d5c232c183c09689d75c177b01bef60e6dd53639"
+ "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639",
- "reference": "d5c232c183c09689d75c177b01bef60e6dd53639",
+ "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c",
+ "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c",
"shasum": ""
},
"require": {
@@ -1221,10 +1225,10 @@
],
"support": {
"issues": "https://github.com/php-stubs/woocommerce-stubs/issues",
- "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2"
+ "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0"
},
- "time": "2026-11-04T21:47:17+00:00",
- "published-time": "2026-11-04T21:48:23+00:00"
+ "time": "2026-11-12T00:45:07+00:00",
+ "published-time": "2026-11-12T00:47:40+00:00"
},
{
"name": "php-stubs/wordpress-stubs",
@@ -2169,12 +2173,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -2256,6 +2260,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -2327,9 +2332,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2539,6 +2544,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -2709,7 +2715,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -2860,7 +2866,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -3337,8 +3343,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sniccowp/php-scoper-wordpress-excludes",
diff --git a/tests/Fixtures/consumer-b/composer.lock b/tests/Fixtures/consumer-b/composer.lock
index 066b535..9bdb98a 100644
--- a/tests/Fixtures/consumer-b/composer.lock
+++ b/tests/Fixtures/consumer-b/composer.lock
@@ -771,23 +771,23 @@
},
{
"name": "jetbrains/phpstorm-stubs",
- "version": "v2026.2",
+ "version": "v2026.3",
"source": {
"type": "git",
- "url": "https://github.com/JetBrains/phpstorm-stubs",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9"
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
- "phpdocumentor/reflection-docblock": "^5.6",
- "phpunit/phpunit": "^12.3"
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpunit/phpunit": "^13.2"
},
"type": "library",
"autoload": {
@@ -811,8 +811,11 @@
"stubs",
"type"
],
- "time": "2026-06-12T13:19:10+00:00",
- "published-time": "2026-07-22T11:41:13+00:00"
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3"
+ },
+ "time": "2026-10-19T10:54:17+00:00",
+ "published-time": "2026-11-11T13:34:29+00:00"
},
{
"name": "johnbillion/wp-compat",
@@ -892,16 +895,16 @@
},
{
"name": "laravel/serializable-closure",
- "version": "v2.1.0",
+ "version": "v2.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
"shasum": ""
},
"require": {
@@ -949,8 +952,8 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
- "time": "2026-09-22T14:32:34+00:00",
- "published-time": "2026-09-22T14:33:13+00:00"
+ "time": "2026-10-14T21:20:29+00:00",
+ "published-time": "2026-11-10T16:11:08+00:00"
},
{
"name": "nikic/php-parser",
@@ -2083,12 +2086,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -2170,6 +2173,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -2241,9 +2245,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2453,6 +2457,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -2623,7 +2628,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -2774,7 +2779,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -3251,8 +3256,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sniccowp/php-scoper-wordpress-excludes",
diff --git a/tests/Fixtures/personas/bootstrap/composer.lock b/tests/Fixtures/personas/bootstrap/composer.lock
index e7a0cc4..a983027 100644
--- a/tests/Fixtures/personas/bootstrap/composer.lock
+++ b/tests/Fixtures/personas/bootstrap/composer.lock
@@ -663,23 +663,23 @@
},
{
"name": "jetbrains/phpstorm-stubs",
- "version": "v2026.2",
+ "version": "v2026.3",
"source": {
"type": "git",
- "url": "https://github.com/JetBrains/phpstorm-stubs",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9"
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
- "phpdocumentor/reflection-docblock": "^5.6",
- "phpunit/phpunit": "^12.3"
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpunit/phpunit": "^13.2"
},
"type": "library",
"autoload": {
@@ -703,8 +703,11 @@
"stubs",
"type"
],
- "time": "2026-06-12T13:19:10+00:00",
- "published-time": "2026-07-22T11:41:13+00:00"
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3"
+ },
+ "time": "2026-10-19T10:54:17+00:00",
+ "published-time": "2026-11-11T13:34:29+00:00"
},
{
"name": "johnbillion/wp-compat",
@@ -1682,12 +1685,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -1769,6 +1772,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -1840,9 +1844,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2052,6 +2056,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -2222,7 +2227,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -2373,7 +2378,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -2850,8 +2855,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sniccowp/php-scoper-wordpress-excludes",
diff --git a/tests/Fixtures/personas/core/composer.lock b/tests/Fixtures/personas/core/composer.lock
index 38b1e68..6273806 100644
--- a/tests/Fixtures/personas/core/composer.lock
+++ b/tests/Fixtures/personas/core/composer.lock
@@ -736,23 +736,23 @@
},
{
"name": "jetbrains/phpstorm-stubs",
- "version": "v2026.2",
+ "version": "v2026.3",
"source": {
"type": "git",
- "url": "https://github.com/JetBrains/phpstorm-stubs",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9"
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
- "phpdocumentor/reflection-docblock": "^5.6",
- "phpunit/phpunit": "^12.3"
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpunit/phpunit": "^13.2"
},
"type": "library",
"autoload": {
@@ -776,8 +776,11 @@
"stubs",
"type"
],
- "time": "2026-06-12T13:19:10+00:00",
- "published-time": "2026-07-22T11:41:13+00:00"
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3"
+ },
+ "time": "2026-10-19T10:54:17+00:00",
+ "published-time": "2026-11-11T13:34:29+00:00"
},
{
"name": "johnbillion/wp-compat",
@@ -857,16 +860,16 @@
},
{
"name": "laravel/serializable-closure",
- "version": "v2.1.0",
+ "version": "v2.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
"shasum": ""
},
"require": {
@@ -914,8 +917,8 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
- "time": "2026-09-22T14:32:34+00:00",
- "published-time": "2026-09-22T14:33:13+00:00"
+ "time": "2026-10-14T21:20:29+00:00",
+ "published-time": "2026-11-10T16:11:08+00:00"
},
{
"name": "nikic/php-parser",
@@ -2048,12 +2051,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -2135,6 +2138,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -2206,9 +2210,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2418,6 +2422,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -2588,7 +2593,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -2739,7 +2744,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -3216,8 +3221,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sniccowp/php-scoper-wordpress-excludes",
diff --git a/tests/Fixtures/personas/settings/composer.lock b/tests/Fixtures/personas/settings/composer.lock
index 1dc2a35..489f4ec 100644
--- a/tests/Fixtures/personas/settings/composer.lock
+++ b/tests/Fixtures/personas/settings/composer.lock
@@ -771,23 +771,23 @@
},
{
"name": "jetbrains/phpstorm-stubs",
- "version": "v2026.2",
+ "version": "v2026.3",
"source": {
"type": "git",
- "url": "https://github.com/JetBrains/phpstorm-stubs",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9"
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
- "phpdocumentor/reflection-docblock": "^5.6",
- "phpunit/phpunit": "^12.3"
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpunit/phpunit": "^13.2"
},
"type": "library",
"autoload": {
@@ -811,8 +811,11 @@
"stubs",
"type"
],
- "time": "2026-06-12T13:19:10+00:00",
- "published-time": "2026-07-22T11:41:13+00:00"
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3"
+ },
+ "time": "2026-10-19T10:54:17+00:00",
+ "published-time": "2026-11-11T13:34:29+00:00"
},
{
"name": "johnbillion/wp-compat",
@@ -1891,12 +1894,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -1978,6 +1981,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -2049,9 +2053,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2261,6 +2265,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -2431,7 +2436,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -2582,7 +2587,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -3059,8 +3064,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sniccowp/php-scoper-wordpress-excludes",
diff --git a/tests/Fixtures/personas/shared/composer.lock b/tests/Fixtures/personas/shared/composer.lock
index c4cf4c5..766148d 100644
--- a/tests/Fixtures/personas/shared/composer.lock
+++ b/tests/Fixtures/personas/shared/composer.lock
@@ -667,23 +667,23 @@
},
{
"name": "jetbrains/phpstorm-stubs",
- "version": "v2026.2",
+ "version": "v2026.3",
"source": {
"type": "git",
- "url": "https://github.com/JetBrains/phpstorm-stubs",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9"
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
- "phpdocumentor/reflection-docblock": "^5.6",
- "phpunit/phpunit": "^12.3"
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpunit/phpunit": "^13.2"
},
"type": "library",
"autoload": {
@@ -707,8 +707,11 @@
"stubs",
"type"
],
- "time": "2026-06-12T13:19:10+00:00",
- "published-time": "2026-07-22T11:41:13+00:00"
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3"
+ },
+ "time": "2026-10-19T10:54:17+00:00",
+ "published-time": "2026-11-11T13:34:29+00:00"
},
{
"name": "johnbillion/wp-compat",
@@ -1686,12 +1689,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -1773,6 +1776,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -1844,9 +1848,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2056,6 +2060,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -2226,7 +2231,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -2377,7 +2382,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -2854,8 +2859,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sniccowp/php-scoper-wordpress-excludes",
diff --git a/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php b/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php
index d0278ec..b11e502 100644
--- a/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php
+++ b/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php
@@ -8,8 +8,8 @@
* Requires at least: 7.1
* Requires PHP: 8.5
* Requires Plugins: woocommerce
- * WC requires at least: 11.2
- * WC tested up to: 11.2
+ * WC requires at least: 11.3
+ * WC tested up to: 11.3
* Author: Acme
* License: GPL-3.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
diff --git a/tests/Fixtures/personas/woocommerce/composer.json b/tests/Fixtures/personas/woocommerce/composer.json
index b87afdf..3de724b 100644
--- a/tests/Fixtures/personas/woocommerce/composer.json
+++ b/tests/Fixtures/personas/woocommerce/composer.json
@@ -43,7 +43,7 @@
"ahegyes/wordpress-configs": "dev-trunk",
"ahegyes/wp-framework-woocommerce": "^2.0",
"humbug/php-scoper": "^0.18",
- "php-stubs/woocommerce-stubs": "^11.2"
+ "php-stubs/woocommerce-stubs": "^11.3"
},
"minimum-stability": "dev",
diff --git a/tests/Fixtures/personas/woocommerce/composer.lock b/tests/Fixtures/personas/woocommerce/composer.lock
index 357f4bf..b8818bf 100644
--- a/tests/Fixtures/personas/woocommerce/composer.lock
+++ b/tests/Fixtures/personas/woocommerce/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "54ead8d182aa937ebeadd23c9de90674",
+ "content-hash": "2798ab16164aa0370f362a1cc1d7a045",
"packages": [],
"packages-dev": [
{
@@ -290,7 +290,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/woocommerce",
- "reference": "79d6aea22490f658088fcea374c11a136074961f"
+ "reference": "7aa4faa5c982e5ba2e2ed72bdf6255e8a6cf79e9"
},
"require": {
"ahegyes/wp-framework-settings": "^2.0",
@@ -302,6 +302,7 @@
"dev-trunk": "2.x-dev"
},
"requires-wp": "7.1",
+ "requires-wc": "11.3",
"requires-plugins": [
"woocommerce"
],
@@ -812,23 +813,23 @@
},
{
"name": "jetbrains/phpstorm-stubs",
- "version": "v2026.2",
+ "version": "v2026.3",
"source": {
"type": "git",
- "url": "https://github.com/JetBrains/phpstorm-stubs",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9"
+ "url": "https://github.com/JetBrains/phpstorm-stubs.git",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9",
- "reference": "709e512210784a7c0a677b3a89d35def844a59b9",
+ "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd",
+ "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd",
"shasum": ""
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.86",
"nikic/php-parser": "^v5.6",
- "phpdocumentor/reflection-docblock": "^5.6",
- "phpunit/phpunit": "^12.3"
+ "phpdocumentor/reflection-docblock": "^6.0",
+ "phpunit/phpunit": "^13.2"
},
"type": "library",
"autoload": {
@@ -852,8 +853,11 @@
"stubs",
"type"
],
- "time": "2026-06-12T13:19:10+00:00",
- "published-time": "2026-07-22T11:41:13+00:00"
+ "support": {
+ "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3"
+ },
+ "time": "2026-10-19T10:54:17+00:00",
+ "published-time": "2026-11-11T13:34:29+00:00"
},
{
"name": "johnbillion/wp-compat",
@@ -991,16 +995,16 @@
},
{
"name": "php-stubs/woocommerce-stubs",
- "version": "v11.2.2",
+ "version": "v11.3.0",
"source": {
"type": "git",
"url": "https://github.com/php-stubs/woocommerce-stubs.git",
- "reference": "d5c232c183c09689d75c177b01bef60e6dd53639"
+ "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639",
- "reference": "d5c232c183c09689d75c177b01bef60e6dd53639",
+ "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c",
+ "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c",
"shasum": ""
},
"require": {
@@ -1029,10 +1033,10 @@
],
"support": {
"issues": "https://github.com/php-stubs/woocommerce-stubs/issues",
- "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2"
+ "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0"
},
- "time": "2026-11-04T21:47:17+00:00",
- "published-time": "2026-11-04T21:48:23+00:00"
+ "time": "2026-11-12T00:45:07+00:00",
+ "published-time": "2026-11-12T00:47:40+00:00"
},
{
"name": "php-stubs/wordpress-stubs",
@@ -1977,12 +1981,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
- "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825",
+ "reference": "9fb1049dda127f1732cac541e68f8e490db77825",
"shasum": ""
},
"conflict": {
@@ -2064,6 +2068,7 @@
"barzahlen/barzahlen-php": "<2.0.1",
"baserproject/basercms": "<=5.2.2",
"bassjobsen/bootstrap-3-typeahead": ">4.0.2",
+ "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11",
"bbpress/bbpress": "<2.6.5",
"bcit-ci/codeigniter": "<3.1.3",
"bcosca/fatfree": "<3.7.2",
@@ -2135,9 +2140,9 @@
"contao-components/colorbox": ">=1,<1.6.4.3-dev",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12",
- "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12",
+ "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12",
+ "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12",
@@ -2347,6 +2352,7 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
+ "grpc/grpc": "<1.84.1",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1",
@@ -2517,7 +2523,7 @@
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
"mckenziearts/livewire-markdown-editor": "<1.3",
- "mcp/sdk": ">=0.5,<0.7.1",
+ "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9",
"mdanter/ecc": "<2",
"mediaessenz/mail": "<3.12.5|>=4,<4.2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
@@ -2668,7 +2674,7 @@
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1",
+ "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<=3.4.5",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4",
@@ -3145,8 +3151,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-10T03:11:40+00:00",
- "published-time": "2026-11-10T03:11:44+00:00"
+ "time": "2026-11-12T14:42:16+00:00",
+ "published-time": "2026-11-12T14:42:22+00:00"
},
{
"name": "sniccowp/php-scoper-wordpress-excludes",
diff --git a/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php b/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php
index 2478477..552545e 100644
--- a/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php
+++ b/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php
@@ -39,6 +39,6 @@ public function test_a_network_activated_plugin_blocked_by_woocommerce_keeps_its
self::assertSame( 'blocked', $request['records']['consumer_a']['status'] );
self::assertTrue( $request['records']['schedules_hooked'] );
- self::assertStringContainsString( 'Acme Consumer A benötigt WooCommerce 11.2 oder neuer, daher bleiben seine Funktionen aus, bis WooCommerce aktualisiert ist.', $request['body'] );
+ self::assertStringContainsString( 'Acme Consumer A benötigt WooCommerce 11.3 oder neuer, daher bleiben seine Funktionen aus, bis WooCommerce aktualisiert ist.', $request['body'] );
}
}
diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php
index b1a27a5..8864318 100644
--- a/tests/Unit/PackageManifestsTest.php
+++ b/tests/Unit/PackageManifestsTest.php
@@ -43,7 +43,7 @@ final class PackageManifestsTest extends TestCase {
'woocommerce' => array(
'branch-alias' => array( 'dev-trunk' => '2.x-dev' ),
'requires-plugins' => array( 'woocommerce' ),
- 'requires-wc' => '11.2',
+ 'requires-wc' => '11.3',
'requires-wp' => '7.1',
),
);
From e9d415836d37626251036592b8010a1a00843be6 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:05:45 +0100
Subject: [PATCH 02/23] Expect hooks rooted in the plugin's slug
The framework names every hook it fires for a plugin after the plugin's
storage prefix, or after an id or option name the plugin passes, so a
plugin's hooks carry the dws_ vendor token that a later owner can
migrate out of its stored keys but never out of other people's code.
The bootstrap updater's filter names the vendor outright.
The rows now build PluginBoot, AdminNotices, MigrationRunner,
OptionsPage, ObjectForm and ProductDataTab with the plugin's slug and
expect each hook under the slug in snake case: {root}/boot/*,
{root}/admin_notice/*, {root}/migration/run and its run_now admin-post
action, and {root}/options_page/tabs, {root}/object_form/fields and
{root}/product_data_tab/groups with the surface's id as their last
argument. AdminNotices' routes sit under {slug}/v1, and the updater's
filter is {root}/github_updater/config. The storage trait's slug differs
from its prefix, so a row tells a hook from a stored key.
The runner's hook no longer comes from its option, so the option's
length is bounded by its longest stored name, {option}_high_water, and
the slug's by the hook; a row covers each bound. The fixtures and the
README excerpts pass their plugin's slug, and consumer-a's own hooks
take its slug as their root.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/README.md | 8 +-
packages/settings/README.md | 6 +-
packages/woocommerce/README.md | 2 +-
tests/Fixtures/Settings/acme-object-forms.php | 12 +--
tests/Fixtures/Settings/acme-options-page.php | 13 +++-
.../WooCommerce/acme-product-data.php | 10 +--
.../consumer-a/includes/footprint.php | 2 +-
.../consumer-a/includes/safeguards.php | 4 +-
tests/Fixtures/consumer-a/src/Exports.php | 4 +-
.../Fixtures/consumer-a/src/FaultInjector.php | 2 +-
tests/Fixtures/consumer-a/src/Plugin.php | 10 +--
tests/Fixtures/consumer-a/src/ProductData.php | 2 +-
tests/Fixtures/consumer-b/src/Plugin.php | 8 +-
tests/Fixtures/consumer-b/src/Settings.php | 2 +-
tests/Fixtures/end-to-end/acme-end-to-end.php | 4 +-
.../personas/core/includes/footprint.php | 2 +-
tests/Fixtures/personas/core/src/Plugin.php | 10 +--
.../Fixtures/personas/settings/functions.php | 6 +-
.../personas/settings/src/Settings.php | 10 +++
.../personas/woocommerce/functions.php | 2 +-
.../personas/woocommerce/src/Packing.php | 10 +++
tests/Integration/Bootstrap/UpdaterTest.php | 12 ++-
.../AdminNoticesBlockEditorTest.php | 10 +--
.../AdminNotice/AdminNoticesDismissalTest.php | 10 +--
.../AdminNotice/AdminNoticesStorageTest.php | 2 +-
.../Core/AdminNotice/AdminNoticesTest.php | 12 +--
.../Core/Migration/MigrationFixturesTrait.php | 10 ++-
.../MigrationRunnerInLocalEnvironmentTest.php | 2 +-
.../Core/Migration/MigrationRunnerTest.php | 77 ++++++++++---------
.../Core/PluginBootInLocalEnvironmentTest.php | 6 +-
tests/Integration/Core/PluginBootTest.php | 12 +--
.../Settings/AdminPage/OptionsPageTest.php | 9 ++-
.../EditScreen/Hosts/PostMetaBoxTest.php | 14 ++--
.../EditScreen/Hosts/TermFormTest.php | 6 +-
.../EditScreen/Hosts/UserProfileTest.php | 6 +-
.../Settings/EditScreen/ObjectFormTest.php | 14 ++--
.../Support/IsolatesStorageTrait.php | 10 ++-
.../Integration/Support/consumer-request.php | 14 ++--
.../EditScreen/Hosts/OrderMetaBoxTest.php | 10 ++-
.../ProductData/ProductDataTabTest.php | 15 ++--
tests/Php74/Support/php74-request.php | 2 +-
41 files changed, 224 insertions(+), 158 deletions(-)
diff --git a/packages/core/README.md b/packages/core/README.md
index ceb47b3..c70aa6c 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -141,7 +141,7 @@ final class Plugin {
public function __construct() {
$this->logger = new ErrorLogLogger( self::SLUG );
$this->scheduler = new Scheduler( self::PREFIX );
- $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger );
+ $this->boundary = new PluginBoot( ACME_PC_FILE, self::SLUG, self::PREFIX, $this->logger );
}
// ...
@@ -153,7 +153,7 @@ final class Plugin {
// ...
protected function compose(): bool {
- $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
@@ -169,7 +169,7 @@ final class Plugin {
PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger,
Scheduler::class => static fn (): Scheduler => $scheduler,
SchedulerInterface::class => static fn (): Scheduler => $scheduler,
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ),
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
);
}
@@ -201,7 +201,7 @@ function acme_pc_get_footprint(): array {
'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
'network_options' => array( Plugin::PREFIX . '_network_notices' ),
- 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ),
+ 'cron_hooks' => array( 'acme_persona_core/migration/run' ),
'as_groups' => array( Plugin::PREFIX ),
);
}
diff --git a/packages/settings/README.md b/packages/settings/README.md
index bc92082..f20518b 100644
--- a/packages/settings/README.md
+++ b/packages/settings/README.md
@@ -104,11 +104,11 @@ add_action( 'plugins_loaded', 'acme_ps_register_hooks', 15 );
// ...
function acme_ps_register_hooks(): void {
- $notices = new AdminNotices( Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
+ $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
$notices->register_hooks();
- new OptionsPage( Settings::PREFIX, new Settings() )->register_hooks();
- new ObjectForm( Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
+ new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
+ new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
}
```
diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md
index bd0579a..fd9177f 100644
--- a/packages/woocommerce/README.md
+++ b/packages/woocommerce/README.md
@@ -114,7 +114,7 @@ function acme_pw_register_hooks(): void {
return;
}
- new ProductDataTab( Packing::PREFIX, new Packing() )->register_hooks();
+ new ProductDataTab( Packing::SLUG, Packing::PREFIX, new Packing() )->register_hooks();
add_filter( 'woocommerce_get_settings_pages', 'acme_pw_add_settings_tab' );
add_action( 'woocommerce_update_options_' . Packing::PREFIX, 'acme_pw_log_settings_save' );
}
diff --git a/tests/Fixtures/Settings/acme-object-forms.php b/tests/Fixtures/Settings/acme-object-forms.php
index 781a428..38ba416 100644
--- a/tests/Fixtures/Settings/acme-object-forms.php
+++ b/tests/Fixtures/Settings/acme-object-forms.php
@@ -66,22 +66,22 @@ public function update_values( int $object_id, array $values ): void {
};
}
- $notices = new AdminNotices( $spec['id'], new ErrorLogLogger( 'acme' ) );
+ $notices = new AdminNotices( $spec['slug'], $spec['id'], new ErrorLogLogger( 'acme' ) );
$notices->register_hooks();
- new ObjectForm( $spec['id'], $definition, $host, $notices, $store )->register_hooks();
+ new ObjectForm( $spec['slug'], $spec['id'], $definition, $host, $notices, $store )->register_hooks();
}
);
if ( isset( $spec['contributed'] ) ) {
add_filter(
- "{$spec['id']}/object_form/fields",
- static function ( array $fields, int $object_id ) use ( $spec ): array {
- $GLOBALS['acme_outcome']['records'][] = array( 'contributed', $object_id );
+ str_replace( '-', '_', $spec['slug'] ) . '/object_form/fields',
+ static function ( array $fields, int $object_id, string $form_id ) use ( $spec ): array {
+ $GLOBALS['acme_outcome']['records'][] = array( 'contributed', $object_id, $form_id );
return array_merge( $fields, RecordedDefinition::with_behaviors( $spec['contributed'] ) );
},
10,
- 2
+ 3
);
}
diff --git a/tests/Fixtures/Settings/acme-options-page.php b/tests/Fixtures/Settings/acme-options-page.php
index 1e0c603..3dbb44a 100644
--- a/tests/Fixtures/Settings/acme-options-page.php
+++ b/tests/Fixtures/Settings/acme-options-page.php
@@ -24,6 +24,7 @@
'plugins_loaded',
static function () use ( $spec, $acme_with_behaviors ): void {
$page = new OptionsPage(
+ $spec['slug'],
$spec['id'],
new RecordedDefinition( static fn (): array => array( 'tabs' => $acme_with_behaviors( $spec['page']['tabs'] ) ) + $spec['page'] )
);
@@ -33,14 +34,20 @@ static function () use ( $spec, $acme_with_behaviors ): void {
if ( isset( $spec['contributed'] ) ) {
add_filter(
- "{$spec['id']}/options_page/tabs",
- static function ( array $tabs ) use ( $spec, $acme_with_behaviors ): array {
+ str_replace( '-', '_', $spec['slug'] ) . '/options_page/tabs',
+ static function ( array $tabs, string $page_id ) use ( $spec, $acme_with_behaviors ): array {
+ if ( $spec['id'] !== $page_id ) {
+ return $tabs;
+ }
+
foreach ( $tabs as &$tab ) {
$tab['fields'] = array_merge( $tab['fields'], $spec['contributed']['fields'][ $tab['slug'] ] ?? array() );
}
return $acme_with_behaviors( array_merge( $tabs, $spec['contributed']['tabs'] ?? array() ) );
- }
+ },
+ 10,
+ 2
);
}
diff --git a/tests/Fixtures/WooCommerce/acme-product-data.php b/tests/Fixtures/WooCommerce/acme-product-data.php
index 1cfbdab..d5ea3af 100644
--- a/tests/Fixtures/WooCommerce/acme-product-data.php
+++ b/tests/Fixtures/WooCommerce/acme-product-data.php
@@ -43,21 +43,21 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec ): ?array {
}
);
- new ProductDataTab( $spec['id'], $definition )->register_hooks();
+ new ProductDataTab( $spec['slug'], $spec['id'], $definition )->register_hooks();
}
);
if ( isset( $spec['contributed'] ) ) {
add_filter(
- "{$spec['id']}/product_data_tab/groups",
- static function ( array $groups, WC_Product $product ) use ( $spec ): array {
- $GLOBALS['acme_outcome']['records'][] = array( 'groups', $product->get_id() );
+ str_replace( '-', '_', $spec['slug'] ) . '/product_data_tab/groups',
+ static function ( array $groups, WC_Product $product, string $tab_id ) use ( $spec ): array {
+ $GLOBALS['acme_outcome']['records'][] = array( 'groups', $product->get_id(), $tab_id );
$groups[] = array( 'fields' => RecordedDefinition::with_behaviors( $spec['contributed'] ) );
return $groups;
},
10,
- 2
+ 3
);
}
diff --git a/tests/Fixtures/consumer-a/includes/footprint.php b/tests/Fixtures/consumer-a/includes/footprint.php
index bb2c845..62e01b7 100644
--- a/tests/Fixtures/consumer-a/includes/footprint.php
+++ b/tests/Fixtures/consumer-a/includes/footprint.php
@@ -28,7 +28,7 @@ function acme_ca_get_footprint(): array {
'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
'network_options' => array( Plugin::PREFIX . '_network_notices' ),
- 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ),
+ 'cron_hooks' => array( 'acme_consumer_a/migration/run' ),
'as_groups' => array( Plugin::PREFIX ),
);
}
diff --git a/tests/Fixtures/consumer-a/includes/safeguards.php b/tests/Fixtures/consumer-a/includes/safeguards.php
index afef6e3..2b4126c 100644
--- a/tests/Fixtures/consumer-a/includes/safeguards.php
+++ b/tests/Fixtures/consumer-a/includes/safeguards.php
@@ -23,7 +23,7 @@
* @return bool
*/
function acme_ca_is_relaxed( $filtered ): bool {
- return 0 < did_action( 'acme_ca/boot/succeeded' ) && false === $filtered;
+ return 0 < did_action( 'acme_consumer_a/boot/succeeded' ) && false === $filtered;
}
/**
@@ -50,5 +50,5 @@ function acme_ca_map_export_capability( array $caps, string $cap ): array {
*
* @param bool $restricted Whether order exports stay locked.
*/
- return acme_ca_is_relaxed( apply_filters( 'acme_ca/exports/should_restrict', true ) ) ? array( 'manage_woocommerce' ) : array( 'do_not_allow' );
+ return acme_ca_is_relaxed( apply_filters( 'acme_consumer_a/exports/should_restrict', true ) ) ? array( 'manage_woocommerce' ) : array( 'do_not_allow' );
}
diff --git a/tests/Fixtures/consumer-a/src/Exports.php b/tests/Fixtures/consumer-a/src/Exports.php
index 7163147..7d99d1c 100644
--- a/tests/Fixtures/consumer-a/src/Exports.php
+++ b/tests/Fixtures/consumer-a/src/Exports.php
@@ -61,11 +61,11 @@ public function send_order_count(): void {
wp_die( esc_html__( 'Only shop managers can export orders.', 'acme-consumer-a' ), 403 );
}
- add_filter( Plugin::PREFIX . '/exports/should_restrict', '__return_false' );
+ add_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' );
try {
$count = $this->count_orders();
} finally {
- remove_filter( Plugin::PREFIX . '/exports/should_restrict', '__return_false' );
+ remove_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' );
}
if ( $count->is_success() ) {
diff --git a/tests/Fixtures/consumer-a/src/FaultInjector.php b/tests/Fixtures/consumer-a/src/FaultInjector.php
index 6eb58e1..00c5cae 100644
--- a/tests/Fixtures/consumer-a/src/FaultInjector.php
+++ b/tests/Fixtures/consumer-a/src/FaultInjector.php
@@ -56,7 +56,7 @@ public static function should_load(): bool {
*/
#[\Override]
public function register_hooks(): void {
- add_filter( Plugin::PREFIX . '/exports/should_restrict', '__return_false' );
+ add_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' );
if ( 'register' === \constant( 'ACME_CA_FAULT' ) ) {
throw new \RuntimeException( 'The fault injector failed its hook registration.' );
}
diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php
index 3cce1e7..0d8d792 100644
--- a/tests/Fixtures/consumer-a/src/Plugin.php
+++ b/tests/Fixtures/consumer-a/src/Plugin.php
@@ -25,7 +25,7 @@ final class Plugin {
// region FIELDS AND CONSTANTS
/**
- * The plugin's slug, which is also its text domain and its log source.
+ * The plugin's slug, which is also its text domain, its log source and, in snake case, the root of its hook names.
*
* @since 1.0.0
* @version 1.0.0
@@ -35,7 +35,7 @@ final class Plugin {
public const string SLUG = 'acme-consumer-a';
/**
- * The prefix of the plugin's hook names, options, transients and scheduled jobs.
+ * The prefix of the plugin's options, transients and scheduled jobs.
*
* @since 1.0.0
* @version 1.0.0
@@ -124,7 +124,7 @@ final class Plugin {
public function __construct() {
$this->logger = new WooCommerceLogger( self::SLUG, \defined( 'ACME_CA_DEBUG' ) && true === \constant( 'ACME_CA_DEBUG' ) ? 'debug' : null );
$this->scheduler = new Scheduler( self::PREFIX );
- $this->boundary = new PluginBoot( ACME_CA_FILE, self::PREFIX, $this->logger );
+ $this->boundary = new PluginBoot( ACME_CA_FILE, self::SLUG, self::PREFIX, $this->logger );
}
// endregion
@@ -161,7 +161,7 @@ protected function compose(): bool {
return false;
}
- $this->migrations = new MigrationRunner( ACME_CA_FILE, self::PREFIX . '_revision', acme_ca_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( ACME_CA_FILE, self::SLUG, self::PREFIX . '_revision', acme_ca_get_migration_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
@@ -186,7 +186,7 @@ protected function get_definitions( PsrLoggerInterface $logger, Scheduler $sched
PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger,
Scheduler::class => static fn (): Scheduler => $scheduler,
SchedulerInterface::class => static fn (): Scheduler => $scheduler,
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ),
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
);
}
diff --git a/tests/Fixtures/consumer-a/src/ProductData.php b/tests/Fixtures/consumer-a/src/ProductData.php
index eff375f..059a6c5 100644
--- a/tests/Fixtures/consumer-a/src/ProductData.php
+++ b/tests/Fixtures/consumer-a/src/ProductData.php
@@ -39,7 +39,7 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI
*/
#[\Override]
public function register_hooks(): void {
- new ProductDataTab( Plugin::PREFIX . '_packing', $this )->register_hooks();
+ new ProductDataTab( Plugin::SLUG, Plugin::PREFIX . '_packing', $this )->register_hooks();
}
/**
diff --git a/tests/Fixtures/consumer-b/src/Plugin.php b/tests/Fixtures/consumer-b/src/Plugin.php
index 1347108..19e70df 100644
--- a/tests/Fixtures/consumer-b/src/Plugin.php
+++ b/tests/Fixtures/consumer-b/src/Plugin.php
@@ -21,7 +21,7 @@ final class Plugin {
// region FIELDS AND CONSTANTS
/**
- * The plugin's slug, which is also its text domain and its log channel.
+ * The plugin's slug, which is also its text domain, its log channel and, in snake case, the root of its hook names.
*
* @since 1.0.0
* @version 1.0.0
@@ -31,7 +31,7 @@ final class Plugin {
public const string SLUG = 'acme-consumer-b';
/**
- * The prefix of the plugin's hook names, options and admin page.
+ * The prefix of the plugin's options and admin page.
*
* @since 1.0.0
* @version 1.0.0
@@ -95,7 +95,7 @@ final class Plugin {
*/
public function __construct() {
$this->logger = new ErrorLogLogger( self::SLUG );
- $this->boundary = new PluginBoot( ACME_CB_FILE, self::PREFIX, $this->logger );
+ $this->boundary = new PluginBoot( ACME_CB_FILE, self::SLUG, self::PREFIX, $this->logger );
}
// endregion
@@ -145,7 +145,7 @@ protected function compose(): bool {
*/
protected function get_definitions( PsrLoggerInterface $logger ): array {
return array(
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ),
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
);
}
diff --git a/tests/Fixtures/consumer-b/src/Settings.php b/tests/Fixtures/consumer-b/src/Settings.php
index 40a0ddc..1524b88 100644
--- a/tests/Fixtures/consumer-b/src/Settings.php
+++ b/tests/Fixtures/consumer-b/src/Settings.php
@@ -25,7 +25,7 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa
*/
#[\Override]
public function register_hooks(): void {
- new OptionsPage( Plugin::PREFIX, $this )->register_hooks();
+ new OptionsPage( Plugin::SLUG, Plugin::PREFIX, $this )->register_hooks();
}
/**
diff --git a/tests/Fixtures/end-to-end/acme-end-to-end.php b/tests/Fixtures/end-to-end/acme-end-to-end.php
index ffab61b..bce4162 100644
--- a/tests/Fixtures/end-to-end/acme-end-to-end.php
+++ b/tests/Fixtures/end-to-end/acme-end-to-end.php
@@ -19,7 +19,7 @@
// wp-env maps the repository to wp-content/mu-plugins/wp-framework and this directory into the plugins directory.
require_once WPMU_PLUGIN_DIR . '/wp-framework/vendor/autoload.php';
-$acme_notices = new AdminNotices( 'acme_e2e', new ErrorLogLogger( 'acme-end-to-end' ) );
+$acme_notices = new AdminNotices( 'acme-e2e', 'acme_e2e', new ErrorLogLogger( 'acme-end-to-end' ) );
register_activation_hook(
__FILE__,
@@ -56,7 +56,7 @@ public function get_object_form( string $form_id, int $object_id ): array {
);
}
};
- new ObjectForm( 'acme_e2e_details', $definition, new PostMetaBox( array( 'post' ) ), $acme_notices )->register_hooks();
+ new ObjectForm( 'acme-e2e', 'acme_e2e_details', $definition, new PostMetaBox( array( 'post' ) ), $acme_notices )->register_hooks();
}
);
diff --git a/tests/Fixtures/personas/core/includes/footprint.php b/tests/Fixtures/personas/core/includes/footprint.php
index 9b3c0ad..da54f8d 100644
--- a/tests/Fixtures/personas/core/includes/footprint.php
+++ b/tests/Fixtures/personas/core/includes/footprint.php
@@ -26,7 +26,7 @@ function acme_pc_get_footprint(): array {
'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
'network_options' => array( Plugin::PREFIX . '_network_notices' ),
- 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ),
+ 'cron_hooks' => array( 'acme_persona_core/migration/run' ),
'as_groups' => array( Plugin::PREFIX ),
);
}
diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php
index a2cf80e..8ebdf3d 100644
--- a/tests/Fixtures/personas/core/src/Plugin.php
+++ b/tests/Fixtures/personas/core/src/Plugin.php
@@ -24,7 +24,7 @@ final class Plugin {
// region FIELDS AND CONSTANTS
/**
- * The plugin's slug, which is also its text domain and its log channel.
+ * The plugin's slug, which is also its text domain, its log channel and, in snake case, the root of its hook names.
*
* @since 1.0.0
* @version 1.0.0
@@ -34,7 +34,7 @@ final class Plugin {
public const string SLUG = 'acme-persona-core';
/**
- * The prefix of the plugin's hook names, options and scheduled hooks.
+ * The prefix of the plugin's options and scheduled actions.
*
* @since 1.0.0
* @version 1.0.0
@@ -120,7 +120,7 @@ final class Plugin {
public function __construct() {
$this->logger = new ErrorLogLogger( self::SLUG );
$this->scheduler = new Scheduler( self::PREFIX );
- $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger );
+ $this->boundary = new PluginBoot( ACME_PC_FILE, self::SLUG, self::PREFIX, $this->logger );
}
// endregion
@@ -152,7 +152,7 @@ public function boot(): void {
* @return bool
*/
protected function compose(): bool {
- $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
@@ -177,7 +177,7 @@ protected function get_definitions( PsrLoggerInterface $logger, Scheduler $sched
PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger,
Scheduler::class => static fn (): Scheduler => $scheduler,
SchedulerInterface::class => static fn (): Scheduler => $scheduler,
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ),
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
);
}
diff --git a/tests/Fixtures/personas/settings/functions.php b/tests/Fixtures/personas/settings/functions.php
index ee7ff9e..394d170 100644
--- a/tests/Fixtures/personas/settings/functions.php
+++ b/tests/Fixtures/personas/settings/functions.php
@@ -29,9 +29,9 @@
* @return void
*/
function acme_ps_register_hooks(): void {
- $notices = new AdminNotices( Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
+ $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
$notices->register_hooks();
- new OptionsPage( Settings::PREFIX, new Settings() )->register_hooks();
- new ObjectForm( Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
+ new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
+ new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
}
diff --git a/tests/Fixtures/personas/settings/src/Settings.php b/tests/Fixtures/personas/settings/src/Settings.php
index 0c47af5..8d605c2 100644
--- a/tests/Fixtures/personas/settings/src/Settings.php
+++ b/tests/Fixtures/personas/settings/src/Settings.php
@@ -15,6 +15,16 @@
final class Settings implements OptionsPageDefinitionInterface {
// region FIELDS AND CONSTANTS
+ /**
+ * The plugin's slug, which is also its text domain and, in snake case, the root of its hook names.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var string
+ */
+ public const string SLUG = 'acme-persona-settings';
+
/**
* The prefix of the plugin's admin page and option.
*
diff --git a/tests/Fixtures/personas/woocommerce/functions.php b/tests/Fixtures/personas/woocommerce/functions.php
index bc7c8aa..3c46f95 100644
--- a/tests/Fixtures/personas/woocommerce/functions.php
+++ b/tests/Fixtures/personas/woocommerce/functions.php
@@ -33,7 +33,7 @@ function acme_pw_register_hooks(): void {
return;
}
- new ProductDataTab( Packing::PREFIX, new Packing() )->register_hooks();
+ new ProductDataTab( Packing::SLUG, Packing::PREFIX, new Packing() )->register_hooks();
add_filter( 'woocommerce_get_settings_pages', 'acme_pw_add_settings_tab' );
add_action( 'woocommerce_update_options_' . Packing::PREFIX, 'acme_pw_log_settings_save' );
}
diff --git a/tests/Fixtures/personas/woocommerce/src/Packing.php b/tests/Fixtures/personas/woocommerce/src/Packing.php
index dffde59..9df3aa4 100644
--- a/tests/Fixtures/personas/woocommerce/src/Packing.php
+++ b/tests/Fixtures/personas/woocommerce/src/Packing.php
@@ -16,6 +16,16 @@
final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsDefinitionInterface {
// region FIELDS AND CONSTANTS
+ /**
+ * The plugin's slug, which is also its text domain and, in snake case, the root of its hook names.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var string
+ */
+ public const string SLUG = 'acme-persona-woocommerce';
+
/**
* The prefix of the plugin's tabs, options and meta keys.
*
diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php
index b1128fa..ddd71a5 100644
--- a/tests/Integration/Bootstrap/UpdaterTest.php
+++ b/tests/Integration/Bootstrap/UpdaterTest.php
@@ -16,6 +16,8 @@ final class UpdaterTest extends TestCase {
protected string $slug;
+ protected string $hook_root;
+
protected array $responses = array();
protected array $requests = array();
@@ -30,7 +32,9 @@ public static function setUpBeforeClass(): void {
#[\Override]
protected function setUp(): void {
- $this->slug = 'acme-updater-' . \bin2hex( \random_bytes( 3 ) );
+ $suffix = \bin2hex( \random_bytes( 3 ) );
+ $this->slug = "acme-updater-$suffix";
+ $this->hook_root = "acme_updater_$suffix";
add_filter( 'pre_http_request', array( $this, 'answer_request' ), 10, 3 );
}
@@ -441,7 +445,7 @@ public function test_the_configuration_filter_runs_at_check_time_with_the_defaul
Updater::register( $this->write_plugin( $this->slug ) );
$calls = array();
add_filter(
- "dws_github_updater/config/$this->slug",
+ "$this->hook_root/github_updater/config",
static function ( array $config, string $plugin_basename ) use ( &$calls ): array {
$calls[] = array( $config, $plugin_basename );
@@ -467,7 +471,7 @@ static function ( array $config, string $plugin_basename ) use ( &$calls ): arra
#[DataProvider( 'unusable_configurations' )]
public function test_a_disabled_or_unusable_configuration_offers_nothing_and_leaves_details_to_wordpress( \Closure $filter ): void {
Updater::register( $this->write_plugin( $this->slug ) );
- add_filter( "dws_github_updater/config/$this->slug", $filter );
+ add_filter( "$this->hook_root/github_updater/config", $filter );
$this->stage_latest( $this->release( 'v2.0.0' ) );
$updates = $this->check_for_updates();
@@ -481,7 +485,7 @@ public function test_a_disabled_or_unusable_configuration_offers_nothing_and_lea
public function test_a_filtered_repository_asset_and_channel_drive_the_request_the_package_and_the_cache(): void {
Updater::register( $this->write_plugin( $this->slug ) );
add_filter(
- "dws_github_updater/config/$this->slug",
+ "$this->hook_root/github_updater/config",
static fn (): array => array(
'repository' => 'acme/elsewhere',
'asset' => 'bundle.zip',
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php
index 821c4df..6a1da0d 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php
@@ -76,7 +76,7 @@ public function test_the_delivery_endpoint_answers_only_a_logged_in_user(): void
}
public function test_a_delivered_message_is_filtered_and_keeps_only_post_markup(): void {
- add_filter( "$this->prefix/admin_notice/message", static fn ( string $message ): string => "$message ", 10, 3 );
+ add_filter( "$this->hook_root/admin_notice/message", static fn ( string $message ): string => "$message ", 10, 3 );
$this->notices()->add( $this->notice( 'saved', message: 'Read the guide.' ), $this->admin );
wp_set_current_user( $this->admin );
@@ -126,7 +126,7 @@ public function test_the_script_loads_after_its_dependencies_with_the_delivery_p
self::assertSame(
array(
'prefix' => $this->prefix,
- 'path' => \str_replace( '_', '-', $this->prefix ) . '/v1/notices/deliveries',
+ 'path' => "$this->slug/v1/notices/deliveries",
'waiting' => false,
),
$settings
@@ -138,7 +138,7 @@ public function test_registered_hooks_load_the_script_on_the_editors_assets_and_
$notices->register_hooks();
self::assertSame( 10, has_action( 'enqueue_block_editor_assets', array( $notices, 'enqueue_delivery_script' ) ) );
- self::assertArrayHasKey( '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/deliveries', rest_get_server()->get_routes() );
+ self::assertArrayHasKey( "/$this->slug/v1/notices/deliveries", rest_get_server()->get_routes() );
}
#[Group( 'multisite' )]
@@ -202,11 +202,11 @@ protected function load_editor( int $user_id ): array {
}
protected function deliver(): \WP_REST_Response {
- return rest_do_request( new \WP_REST_Request( 'POST', '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/deliveries' ) );
+ return rest_do_request( new \WP_REST_Request( 'POST', "/$this->slug/v1/notices/deliveries" ) );
}
protected function notices(): AdminNotices {
- return new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) );
+ return new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
}
protected function script_handle(): string {
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php
index 6640ec2..76aa006 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php
@@ -39,9 +39,9 @@ protected function setUp(): void {
$this->admin = $this->make_user( 'acme_notices_admin', 'administrator' );
$this->other_admin = $this->make_user( 'acme_notices_other_admin', 'administrator' );
$this->logger = new RecordingLogger();
- $this->notices = new AdminNotices( $this->prefix, $this->logger );
+ $this->notices = new AdminNotices( $this->slug, $this->prefix, $this->logger );
add_action(
- "$this->prefix/admin_notice/dismissed",
+ "$this->hook_root/admin_notice/dismissed",
function ( mixed ...$arguments ): void {
$this->dismissed[] = $arguments;
},
@@ -271,7 +271,7 @@ public function test_a_failing_store_answers_500_and_is_logged( bool $per_viewer
}
public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches_the_error_log(): void {
- $notices = new AdminNotices( $this->prefix, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) );
+ $notices = new AdminNotices( $this->slug, $this->prefix, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) );
$notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) );
$request = new \WP_REST_Request( 'POST' );
$request->set_body_params( $this->shown( $this->admin, 'site', 'x' ) );
@@ -384,7 +384,7 @@ protected function render( int $user_id, string $screen = 'dashboard' ): array {
}
protected function dismiss( array $params ): \WP_REST_Response {
- $request = new \WP_REST_Request( 'POST', '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/dismissals' );
+ $request = new \WP_REST_Request( 'POST', "/$this->slug/v1/notices/dismissals" );
$request->set_body_params( \array_filter( $params, static fn ( mixed $value ): bool => ! \is_null( $value ) ) );
return rest_do_request( $request );
@@ -404,7 +404,7 @@ protected function serve( array $params, ?string $rest_nonce ): array {
$_POST = $params;
\ob_start();
- rest_get_server()->serve_request( '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/dismissals' );
+ rest_get_server()->serve_request( "/$this->slug/v1/notices/dismissals" );
return \json_decode( (string) \ob_get_clean(), true );
}
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
index 174dad9..aab3e60 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
@@ -27,7 +27,7 @@ final class AdminNoticesStorageTest extends TestCase {
#[\Override]
protected function setUp(): void {
$this->user_id = $this->make_user( 'acme_notices_admin', 'administrator' );
- $this->notices = new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) );
+ $this->notices = new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
wp_set_current_user( $this->user_id );
}
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
index 0356e67..11eb6fa 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
@@ -62,12 +62,12 @@ public static function invalid_prefixes(): array {
public function test_a_prefix_outside_the_pattern_or_with_a_reserved_segment_is_rejected( string $prefix ): void {
$this->expectException( InvalidArgumentException::class );
- new AdminNotices( $prefix, $this->logger );
+ new AdminNotices( $this->slug, $prefix, $this->logger );
}
public function test_a_prefix_may_hold_the_reserved_words_inside_longer_segments_and_reach_32_characters(): void {
foreach ( array( 'acme_notice', 'acme_notices', 'acmenetwork_x', 'acme_hiddenx', 'a' . \str_repeat( 'b', 31 ) ) as $prefix ) {
- $notices = new AdminNotices( $prefix, $this->logger );
+ $notices = new AdminNotices( $this->slug, $prefix, $this->logger );
self::assertSame( array(), $notices->get_notices( NoticeScope::Request ) );
}
}
@@ -285,7 +285,7 @@ public function test_a_notice_that_has_not_expired_yet_is_listed_and_shows(): vo
public function test_the_visibility_filter_hides_a_notice_without_consuming_it_unless_it_returns_true(): void {
$calls = array();
add_filter(
- "$this->prefix/admin_notice/should_show",
+ "$this->hook_root/admin_notice/should_show",
static function ( mixed $visible, string $id, string $scope ) use ( &$calls ): mixed {
$calls[] = array( $visible, $id, $scope );
return match ( $id ) {
@@ -310,7 +310,7 @@ static function ( mixed $visible, string $id, string $scope ) use ( &$calls ): m
public function test_the_message_filter_replaces_the_printed_message_unless_it_returns_no_string(): void {
$calls = array();
add_filter(
- "$this->prefix/admin_notice/message",
+ "$this->hook_root/admin_notice/message",
static function ( string $message, string $id, string $scope ) use ( &$calls ): ?string {
$calls[] = array( $message, $id, $scope );
return 'kept' === $id ? null : 'Translated.';
@@ -371,7 +371,7 @@ public function test_the_dismiss_script_loads_on_wp_api_fetch_only_after_a_persi
self::assertSame(
array(
'prefix' => $this->prefix,
- 'path' => \str_replace( '_', '-', $this->prefix ) . '/v1/notices/dismissals',
+ 'path' => "$this->slug/v1/notices/dismissals",
),
\json_decode( $matches[1], true )
);
@@ -464,7 +464,7 @@ public function test_a_network_notice_shows_on_every_site_and_in_the_network_adm
}
protected function notices(): AdminNotices {
- return new AdminNotices( $this->prefix, $this->logger );
+ return new AdminNotices( $this->slug, $this->prefix, $this->logger );
}
// A fresh instance stands for the next request.
diff --git a/tests/Integration/Core/Migration/MigrationFixturesTrait.php b/tests/Integration/Core/Migration/MigrationFixturesTrait.php
index 30caa3b..0829d1e 100644
--- a/tests/Integration/Core/Migration/MigrationFixturesTrait.php
+++ b/tests/Integration/Core/Migration/MigrationFixturesTrait.php
@@ -9,9 +9,17 @@
trait MigrationFixturesTrait {
protected string $option;
+ protected string $slug;
+
+ protected string $hook_root;
+
+ // The slug differs from the option, so a row tells the runner's hook from its stored names.
#[Before]
protected function use_a_fresh_option(): void {
- $this->option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) );
+ $suffix = \bin2hex( \random_bytes( 4 ) );
+ $this->option = "acme_db_$suffix";
+ $this->slug = "acme-runner-$suffix";
+ $this->hook_root = "acme_runner_$suffix";
}
#[After]
diff --git a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php
index 7a12703..200f5cf 100644
--- a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php
+++ b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php
@@ -83,6 +83,6 @@ function (): void {
}
protected function make_runner( array $steps ): MigrationRunner {
- return new MigrationRunner( __DIR__ . '/../../../Fixtures/Core/acme-boot.php', $this->option, $steps, new PsrNullLogger(), new Scheduler( 'acme' ) );
+ return new MigrationRunner( __DIR__ . '/../../../Fixtures/Core/acme-boot.php', $this->slug, $this->option, $steps, new PsrNullLogger(), new Scheduler( 'acme' ) );
}
}
diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php
index c166745..c2b8ebf 100644
--- a/tests/Integration/Core/Migration/MigrationRunnerTest.php
+++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php
@@ -78,9 +78,9 @@ protected function tearDown(): void {
global $wpdb;
\ini_set( 'memory_limit', $this->memory_limit );
- wp_unschedule_hook( "$this->option/migration/run" );
+ wp_unschedule_hook( "$this->hook_root/migration/run" );
unset( $_REQUEST['_wpnonce'], $_SERVER['HTTP_REFERER'] );
- $wpdb->delete( $wpdb->actionscheduler_actions, array( 'hook' => "$this->option/migration/run" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the actions the test created.
+ $wpdb->delete( $wpdb->actionscheduler_actions, array( 'hook' => "$this->hook_root/migration/run" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the actions the test created.
}
public function test_pending_steps_run_once_each_in_order_with_a_checkpoint_after_each(): void {
@@ -99,11 +99,18 @@ public function test_steps_below_the_stored_revision_never_run(): void {
self::assertSame( 3, MigrationRunner::get_revision( $this->option ) );
}
- public function test_an_option_whose_scheduled_hook_would_pass_191_bytes_is_refused(): void {
- new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 177 ), array(), $this->logger, $this->make_wp_cron_scheduler() );
+ public function test_a_slug_whose_scheduled_hook_would_pass_191_bytes_is_refused(): void {
+ new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 177 ), $this->option, array(), $this->logger, $this->make_wp_cron_scheduler() );
$this->expectException( InvalidArgumentException::class );
- new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 178 ), array(), $this->logger, $this->make_wp_cron_scheduler() );
+ new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 178 ), $this->option, array(), $this->logger, $this->make_wp_cron_scheduler() );
+ }
+
+ public function test_an_option_whose_high_water_mark_would_pass_191_characters_is_refused(): void {
+ new MigrationRunner( self::PLUGIN_FILE, $this->slug, \str_repeat( 'a', 180 ), array(), $this->logger, $this->make_wp_cron_scheduler() );
+
+ $this->expectException( InvalidArgumentException::class );
+ new MigrationRunner( self::PLUGIN_FILE, $this->slug, \str_repeat( 'a', 181 ), array(), $this->logger, $this->make_wp_cron_scheduler() );
}
public static function current_revisions(): array {
@@ -672,7 +679,7 @@ function ( string $query ): string {
}
public function test_a_batch_time_limit_below_1_second_runs_one_step_per_request(): void {
- $this->load_wordpress( new MigrationRunner( self::PLUGIN_FILE, $this->option, array( $this->step( 1 ), $this->step( 2 ) ), $this->logger, $this->make_wp_cron_scheduler(), 0 ) );
+ $this->load_wordpress( new MigrationRunner( self::PLUGIN_FILE, $this->slug, $this->option, array( $this->step( 1 ), $this->step( 2 ) ), $this->logger, $this->make_wp_cron_scheduler(), 0 ) );
self::assertSame( array( array( 1, 0 ) ), $this->ran );
self::assertSame( 1, MigrationRunner::get_revision( $this->option ) );
@@ -694,10 +701,10 @@ public function test_a_request_outside_the_runner_hook_runs_plain_steps_but_no_b
}
public function test_a_request_outside_the_runner_hook_adds_nothing_while_the_hook_runs_elsewhere(): void {
- as_enqueue_async_action( "$this->option/migration/run", array(), 'acme' );
+ as_enqueue_async_action( "$this->hook_root/migration/run", array(), 'acme' );
$running = as_get_scheduled_actions(
array(
- 'hook' => "$this->option/migration/run",
+ 'hook' => "$this->hook_root/migration/run",
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
@@ -713,7 +720,7 @@ public function test_the_runner_hook_runs_batches_until_the_step_finishes(): voi
$batches = new RecordedBatches( 3 );
$this->make_runner( array( $batches, $this->step( 2 ) ) )->register_hooks();
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
self::assertSame( 3, $batches->processed );
self::assertSame( array( array( 2, 1 ) ), $this->ran );
@@ -736,7 +743,7 @@ function ( mixed $event ) use ( &$locks ): mixed {
}
);
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
self::assertSame( 2, $batches->processed );
self::assertSame( array( null ), $locks );
@@ -751,9 +758,9 @@ public function test_a_batched_step_spans_requests_while_its_revision_stays_behi
$revisions = array();
for ( $request = 1; $request <= 3; ++$request ) {
- remove_all_actions( "$this->option/migration/run" );
+ remove_all_actions( "$this->hook_root/migration/run" );
$this->make_runner( array( $batches ), $clock )->register_hooks();
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
$revisions[] = array( $batches->processed, MigrationRunner::get_revision( $this->option ) );
}
@@ -774,7 +781,7 @@ static function () use ( $clock ): void {
);
$this->load_wordpress( $runner );
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
self::assertSame( 1, $batches->processed );
self::assertSame( 1, MigrationRunner::get_revision( $this->option ) );
@@ -793,7 +800,7 @@ static function (): void {
$batches = new RecordedBatches( 3, static fn ( int $processed ) => 1 === $processed && $takeover->run() );
$this->make_runner( array( $batches ) )->register_hooks();
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
self::assertSame( 1, $batches->processed );
self::assertSame( $later->now->getTimestamp(), $this->get_lock()['time'] ?? null );
@@ -833,7 +840,7 @@ public function test_the_runtime_cache_is_flushed_after_every_batch(): void {
};
$this->make_runner( array( new RecordedBatches( 2, $batch ), new RecordedBatches( 1, $batch ) ) )->register_hooks();
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
self::assertSame( array( false, false, false ), $cached );
self::assertFalse( wp_cache_get( 'acme_batch', 'acme' ) );
@@ -891,7 +898,7 @@ public function test_memory_past_90_percent_of_the_limit_stops_the_loop( bool $t
\ini_set( 'memory_limit', $tight ? (string) (int) ( \memory_get_usage( true ) / 0.93 ) : '-1' );
try {
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
} finally {
\ini_set( 'memory_limit', $previous );
}
@@ -904,7 +911,7 @@ public function test_the_runner_hook_returns_without_enqueueing_while_another_ru
$this->insert_lock( \time() - 5 );
$this->make_runner( array( $batches ) )->register_hooks();
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
self::assertSame( 0, $batches->processed );
self::assertSame( 0, $this->count_pending_runs() );
@@ -922,7 +929,7 @@ static function ( int $processed ): void {
$this->make_runner( array( $this->step( 1 ), $batches ) )->register_hooks();
update_option( $this->option, 1 );
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
$error = get_option( "{$this->option}_error" );
self::assertIsArray( $error );
@@ -948,7 +955,7 @@ public function test_a_successful_batch_clears_the_error_of_an_earlier_failure()
false
);
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
self::assertSame( 1, $batches->processed );
self::assertFalse( get_option( "{$this->option}_error" ) );
@@ -970,9 +977,9 @@ public function test_a_batched_step_finishes_through_real_scheduler_dispatch( st
$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" );
+ remove_all_actions( "$this->hook_root/migration/run" );
$this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) );
- 'action-scheduler' === $kind ? self::run_due_actions( "$this->option/migration/run" ) : self::run_due_cron_events( "$this->option/migration/run" );
+ 'action-scheduler' === $kind ? self::run_due_actions( "$this->hook_root/migration/run" ) : self::run_due_cron_events( "$this->hook_root/migration/run" );
++$requests;
}
@@ -987,16 +994,16 @@ public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_prog
$batches = new RecordedBatches( 5, static fn () => $clock->advance( 30 ) );
$this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) );
- self::run_due_actions( "$this->option/migration/run" );
+ self::run_due_actions( "$this->hook_root/migration/run" );
// A queue run that pauses between actions claims again later, as wp action-scheduler run --pause does.
- self::run_due_actions( "$this->option/migration/run", \time() + MINUTE_IN_SECONDS );
+ self::run_due_actions( "$this->hook_root/migration/run", \time() + MINUTE_IN_SECONDS );
self::assertSame( 1, $batches->processed );
self::assertSame( 0, $this->count_pending_runs() );
- remove_all_actions( "$this->option/migration/run" );
+ remove_all_actions( "$this->hook_root/migration/run" );
$this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) );
- self::run_due_actions( "$this->option/migration/run" );
+ self::run_due_actions( "$this->hook_root/migration/run" );
self::assertSame( 2, $batches->processed );
self::assertSame( 0, MigrationRunner::get_revision( $this->option ) );
@@ -1023,7 +1030,7 @@ static function (): void {
);
$runner = $this->make_runner( array( $batches ) );
$runner->register_hooks();
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
$notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) );
self::assertStringContainsString( self::ERROR_NOTICE . 'The <batch> failed.', $notices );
@@ -1040,8 +1047,8 @@ public function test_a_pending_batched_step_shows_plugin_activators_a_run_now_li
self::assertStringContainsString( 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 );
- self::assertSame( "$this->option/migration/run_now", $query['action'] );
- self::assertSame( 1, wp_verify_nonce( $query['_wpnonce'], "$this->option/migration/run_now" ) );
+ self::assertSame( "$this->hook_root/migration/run_now", $query['action'] );
+ self::assertSame( 1, wp_verify_nonce( $query['_wpnonce'], "$this->hook_root/migration/run_now" ) );
self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-editor', 'editor' ) ) );
}
@@ -1057,7 +1064,7 @@ static function () use ( &$broken ): void {
}
);
$this->make_runner( array( $batches ) )->register_hooks();
- do_action( "$this->option/migration/run" );
+ do_action( "$this->hook_root/migration/run" );
$this->age_lock();
$broken = false;
@@ -1148,7 +1155,7 @@ public function test_run_now_runs_the_runner_hook_and_returns_to_a_local_referri
$batches = new RecordedBatches( 3 );
$this->make_runner( array( $batches ) )->register_hooks();
wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) );
- $_REQUEST['_wpnonce'] = wp_create_nonce( "$this->option/migration/run_now" );
+ $_REQUEST['_wpnonce'] = wp_create_nonce( "$this->hook_root/migration/run_now" );
$_SERVER['HTTP_REFERER'] = \str_starts_with( $referer, 'https://' ) ? $referer : admin_url( $referer );
self::assertSame( admin_url( $destination ), $this->post_run_now() );
@@ -1168,14 +1175,14 @@ public function test_run_now_refuses_an_invalid_nonce_and_users_who_cannot_activ
$batches = new RecordedBatches( 3 );
$this->make_runner( array( $batches ) )->register_hooks();
wp_set_current_user( $this->make_user( "acme-migrations-$role", $role ) );
- $_REQUEST['_wpnonce'] = $valid_nonce ? wp_create_nonce( "$this->option/migration/run_now" ) : 'invalid';
+ $_REQUEST['_wpnonce'] = $valid_nonce ? wp_create_nonce( "$this->hook_root/migration/run_now" ) : 'invalid';
self::assertSame( 'died', $this->post_run_now() );
self::assertSame( 0, $batches->processed );
}
protected function make_runner( array $steps, ?PsrClockInterface $clock = null, ?PsrLoggerInterface $logger = null, ?SchedulerInterface $scheduler = null ): MigrationRunner {
- return new MigrationRunner( self::PLUGIN_FILE, $this->option, $steps, $logger ?? $this->logger, $scheduler ?? $this->make_wp_cron_scheduler(), 20, $clock );
+ return new MigrationRunner( self::PLUGIN_FILE, $this->slug, $this->option, $steps, $logger ?? $this->logger, $scheduler ?? $this->make_wp_cron_scheduler(), 20, $clock );
}
// Action Scheduler below its floor leaves the writes to WP-Cron.
@@ -1242,7 +1249,7 @@ static function ( string $location ): never {
);
try {
- do_action( "admin_post_$this->option/migration/run_now" );
+ do_action( "admin_post_$this->hook_root/migration/run_now" );
} catch ( \OutOfBoundsException | \OverflowException $stop ) {
return $stop->getMessage();
}
@@ -1253,10 +1260,10 @@ static function ( string $location ): never {
protected function count_pending_runs(): int {
$cron = 0;
foreach ( _get_cron_array() as $hooks ) {
- $cron += \count( $hooks[ "$this->option/migration/run" ] ?? array() );
+ $cron += \count( $hooks[ "$this->hook_root/migration/run" ] ?? array() );
}
$query = array(
- 'hook' => "$this->option/migration/run",
+ 'hook' => "$this->hook_root/migration/run",
'status' => \ActionScheduler_Store::STATUS_PENDING,
'per_page' => -1,
);
diff --git a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php
index ff941da..a7e7c1d 100644
--- a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php
+++ b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php
@@ -35,7 +35,7 @@ public static function tearDownAfterClass(): void {
#[\Override]
protected function setUp(): void {
add_action(
- "$this->prefix/boot/succeeded",
+ "$this->hook_root/boot/succeeded",
function (): void {
$this->fired[] = 'succeeded';
},
@@ -43,7 +43,7 @@ function (): void {
0
);
add_action(
- "$this->prefix/boot/failed",
+ "$this->hook_root/boot/failed",
function ( \Throwable $failure ): void {
$this->fired[] = 'failed';
$this->failures[] = $failure;
@@ -54,7 +54,7 @@ function ( \Throwable $failure ): void {
public function test_a_failing_composition_is_rethrown_after_the_failure_action(): void {
self::assertSame( 'local', wp_get_environment_type() );
$failure = new \RuntimeException( 'composition failed' );
- $boot = new PluginBoot( __DIR__ . '/../../Fixtures/Core/acme-boot.php', $this->prefix, new PsrNullLogger() );
+ $boot = new PluginBoot( __DIR__ . '/../../Fixtures/Core/acme-boot.php', $this->slug, $this->prefix, new PsrNullLogger() );
$caught = null;
try {
diff --git a/tests/Integration/Core/PluginBootTest.php b/tests/Integration/Core/PluginBootTest.php
index 914dfdc..a52010d 100644
--- a/tests/Integration/Core/PluginBootTest.php
+++ b/tests/Integration/Core/PluginBootTest.php
@@ -34,7 +34,7 @@ final class PluginBootTest extends TestCase {
protected function setUp(): void {
$this->logger = new RecordingLogger();
add_action(
- "$this->prefix/boot/succeeded",
+ "$this->hook_root/boot/succeeded",
function (): void {
$this->fired[] = 'succeeded';
},
@@ -42,7 +42,7 @@ function (): void {
0
);
add_action(
- "$this->prefix/boot/blocked",
+ "$this->hook_root/boot/blocked",
function (): void {
$this->fired[] = 'blocked';
},
@@ -50,7 +50,7 @@ function (): void {
0
);
add_action(
- "$this->prefix/boot/failed",
+ "$this->hook_root/boot/failed",
function ( \Throwable $failure ): void {
$this->fired[] = 'failed';
$this->failures[] = $failure;
@@ -79,7 +79,7 @@ public function test_a_composition_named_as_a_callable_boots(): void {
public function test_a_composition_that_reports_an_unmet_requirement_is_blocked(): void {
// The all hook receives an action's own arguments, before do_action() pads an argumentless call with an empty string.
$arguments = array();
- $blocked = "$this->prefix/boot/blocked";
+ $blocked = "$this->hook_root/boot/blocked";
add_action(
'all',
static function ( string $hook_name, mixed ...$args ) use ( &$arguments, $blocked ): void {
@@ -140,7 +140,7 @@ public static function logging_failures(): array {
#[DataProvider( 'logging_failures' )]
public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_and_is_announced( \Throwable $logging_failure ): void {
$failure = new \RuntimeException( 'composition failed' );
- $boot = new PluginBoot( self::PLUGIN_FILE, $this->prefix, new ThrowingLogger( $logging_failure ) );
+ $boot = new PluginBoot( self::PLUGIN_FILE, $this->slug, $this->prefix, new ThrowingLogger( $logging_failure ) );
$log_file = (string) \tempnam( \sys_get_temp_dir(), 'acme-log-' );
$previous = \ini_set( 'error_log', $log_file );
@@ -162,6 +162,6 @@ public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_a
}
protected function make_boot(): PluginBoot {
- return new PluginBoot( self::PLUGIN_FILE, $this->prefix, $this->logger );
+ return new PluginBoot( self::PLUGIN_FILE, $this->slug, $this->prefix, $this->logger );
}
}
diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
index 53ed702..05df76e 100644
--- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php
+++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
@@ -81,19 +81,19 @@ public static function relocated_option_pages(): array {
public function test_an_invalid_id_is_rejected( string $id ): void {
$this->expectException( InvalidArgumentException::class );
- new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) );
+ new OptionsPage( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ) );
}
#[DataProvider( 'v1_helper_ids' )]
public function test_every_v1_key_is_a_valid_id( string $id ): void {
$this->expectNotToPerformAssertions();
- new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) );
+ new OptionsPage( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ) );
}
public function test_the_definition_is_evaluated_neither_at_construction_nor_at_registration(): void {
$definition = new RecordedDefinition( static fn (): array => array() );
- $page = new OptionsPage( $this->id, $definition );
+ $page = new OptionsPage( $this->slug, $this->id, $definition );
$page->register_hooks();
@@ -110,7 +110,7 @@ public function test_the_definition_receives_the_pages_id_once(): void {
'tabs' => array(),
)
);
- $page = new OptionsPage( $this->id, $definition );
+ $page = new OptionsPage( $this->slug, $this->id, $definition );
$page->get_capability();
$page->get_capability();
@@ -622,6 +622,7 @@ public function test_saving_the_second_of_two_tabs_on_one_option_keeps_the_first
protected function spec( array $fields, array $page = array(), array $extra = array() ): array {
return array(
'id' => $this->id,
+ 'slug' => $this->slug,
'page' => $page + array(
'parent' => 'options-general.php',
'capability' => 'manage_options',
diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
index d1370e8..d2990fe 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
@@ -70,19 +70,19 @@ public static function heartbeat_refreshes(): array {
public function test_an_invalid_form_id_is_rejected( string $id ): void {
$this->expectException( InvalidArgumentException::class );
- new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) );
+ new ObjectForm( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) );
}
#[DataProvider( 'v1_helper_ids' )]
public function test_every_v1_key_is_a_valid_form_id( string $id ): void {
- $form = new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) );
+ $form = new ObjectForm( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) );
self::assertSame( $id, $form->get_id() );
}
public function test_the_definition_is_evaluated_only_when_a_screen_needs_it(): void {
$definition = new RecordedDefinition( static fn (): array => array() );
- $form = new ObjectForm( $this->id, $definition, new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) );
+ $form = new ObjectForm( $this->slug, $this->id, $definition, new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) );
$form->register_hooks();
$post_id = $this->make_post();
$spec = $this->spec( array( $this->field( 'text', 'name' ) ) );
@@ -230,7 +230,7 @@ public function test_per_object_fields_receive_the_post_and_contributed_fields_r
$box = $this->box( self::document( $request['body'] ) );
self::assertContains( array( 'fields', $post_id ), $request['records'] );
- self::assertContains( array( 'contributed', $post_id ), $request['records'] );
+ self::assertContains( array( 'contributed', $post_id, $this->id ), $request['records'] );
$this->control( $box, 'name' );
$this->control( $box, 'extra' );
}
@@ -531,6 +531,7 @@ public function test_bulk_edit_stores_nothing(): void {
public function test_a_save_without_the_forms_nonce_stores_nothing(): void {
$post_id = $this->make_post( array( "_{$this->prefix}_name" => 'kept' ) );
$form = new ObjectForm(
+ $this->slug,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -540,7 +541,7 @@ public function test_a_save_without_the_forms_nonce_stores_nothing(): void {
)
),
new PostMetaBox( array( 'post' ) ),
- new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
@@ -676,7 +677,7 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th
$saved = $this->block_editor_save( $spec, $post_id, array( $this->control_name( 'note' ) => 'too long' ) );
$redirected = $this->follow( $spec, $saved );
wp_set_current_user( $this->admin );
- $delivered = new AdminNotices( $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver();
+ $delivered = new AdminNotices( $this->slug, $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver();
$list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec );
self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) );
@@ -726,6 +727,7 @@ public function test_only_the_block_editor_prints_the_script_that_refreshes_the_
protected function spec( array $fields, array $form = array(), array $extra = array() ): array {
return $extra + array(
'id' => $this->id,
+ 'slug' => $this->slug,
'host' => 'post',
'host_args' => array( 'post_types' => array( 'post' ) ),
'editor' => 'classic',
diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
index 13aa3a7..a3f76a1 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
@@ -37,7 +37,7 @@ protected function setUp(): void {
public function test_the_host_registers_on_each_of_its_taxonomies(): void {
$host = new TermForm( array( 'category', 'post_tag' ) );
- new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
self::assertSame( 10, has_action( "{$taxonomy}_edit_form", array( $host, 'render_section' ) ) );
@@ -139,6 +139,7 @@ public function test_an_update_in_code_stores_nothing(): void {
$term_id = $this->make_term( 'category' );
add_term_meta( $term_id, "_{$this->prefix}_name", 'kept' );
$form = new ObjectForm(
+ $this->slug,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -148,7 +149,7 @@ public function test_an_update_in_code_stores_nothing(): void {
)
),
new TermForm( array( 'category' ) ),
- new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
@@ -168,6 +169,7 @@ public function test_an_update_in_code_stores_nothing(): void {
protected function spec( array $fields, array $form = array() ): array {
return array(
'id' => $this->id,
+ 'slug' => $this->slug,
'host' => 'term',
'host_args' => array( 'taxonomies' => array( 'category' ) ),
'form' => $form + array(
diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
index 6d699d6..3308729 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
@@ -51,7 +51,7 @@ public static function multisite_profiles(): array {
public function test_the_host_registers_on_both_profile_screens(): void {
$host = new UserProfile();
- new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
self::assertSame( 10, has_action( 'show_user_profile', array( $host, 'render_section' ) ) );
self::assertSame( 10, has_action( 'edit_user_profile', array( $host, 'render_section' ) ) );
@@ -192,6 +192,7 @@ public function test_a_user_editing_themself_without_the_forms_capability_gets_n
public function test_an_update_in_code_stores_nothing(): void {
add_user_meta( $this->subscriber, "_{$this->prefix}_name", 'kept' );
$form = new ObjectForm(
+ $this->slug,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -201,7 +202,7 @@ public function test_an_update_in_code_stores_nothing(): void {
)
),
new UserProfile(),
- new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
@@ -238,6 +239,7 @@ protected function make_profile_user( string $login, string $role ): int {
protected function spec( array $fields, array $form = array() ): array {
return array(
'id' => $this->id,
+ 'slug' => $this->slug,
'host' => 'user',
'host_args' => array(),
'form' => $form + array(
diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
index 9a9e1c3..1b7e0cb 100644
--- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php
+++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
@@ -158,7 +158,7 @@ public function test_a_save_without_the_forms_nonce_stores_nothing_and_evaluates
public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user_who_saved(): void {
wp_set_current_user( $this->author_id );
$this->post_form( $this->post_id );
- $notices = new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) );
+ $notices = new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
$this->rejecting_form( $notices )->save( $this->post_id );
@@ -172,7 +172,7 @@ public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user
public function test_a_second_rejection_of_a_field_replaces_the_first(): void {
wp_set_current_user( $this->author_id );
- $notices = new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) );
+ $notices = new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
$this->post_form( $this->post_id );
$this->rejecting_form( $notices )->save( $this->post_id );
@@ -193,7 +193,7 @@ public function test_a_rejection_whose_notice_cannot_be_stored_is_logged_and_kee
$previous = \ini_set( 'error_log', $log_file );
try {
- $this->rejecting_form( new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->save( $this->post_id );
+ $this->rejecting_form( new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->save( $this->post_id );
$log = (string) \file_get_contents( $log_file );
} finally {
\ini_set( 'error_log', (string) $previous );
@@ -210,7 +210,7 @@ public function test_a_host_serves_one_form(): void {
$this->expectException( LogicException::class );
- new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->slug, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
}
public function test_a_host_the_consumer_writes_serves_one_form_too(): void {
@@ -237,7 +237,7 @@ public function render_rows( string $title, array $rows ): void {}
$this->expectException( LogicException::class );
- new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->slug, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
}
protected function form( ObjectFormHostInterface $host, string $capability = 'edit_posts' ): ObjectForm {
@@ -255,10 +255,11 @@ protected function form( ObjectFormHostInterface $host, string $capability = 'ed
);
return new ObjectForm(
+ $this->slug,
"{$this->prefix}_details",
$this->definition,
$host,
- new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
);
}
@@ -266,6 +267,7 @@ protected function rejecting_form( AdminNotices $notices, string $title = 'Note
$_POST[ "{$this->prefix}_details" ][ "_{$this->prefix}_note" ] = 'Too long.';
return new ObjectForm(
+ $this->slug,
"{$this->prefix}_details",
new RecordedDefinition(
fn (): array => array(
diff --git a/tests/Integration/Support/IsolatesStorageTrait.php b/tests/Integration/Support/IsolatesStorageTrait.php
index 7721952..a1e1b2c 100644
--- a/tests/Integration/Support/IsolatesStorageTrait.php
+++ b/tests/Integration/Support/IsolatesStorageTrait.php
@@ -8,9 +8,17 @@
trait IsolatesStorageTrait {
protected string $prefix;
+ protected string $slug;
+
+ protected string $hook_root;
+
+ // The slug differs from the prefix, so a row tells a hook or REST name from a storage key.
#[Before]
protected function use_a_fresh_prefix(): void {
- $this->prefix = 'acme_' . \bin2hex( \random_bytes( 3 ) );
+ $suffix = \bin2hex( \random_bytes( 3 ) );
+ $this->prefix = "acme_$suffix";
+ $this->slug = "acme-plugin-$suffix";
+ $this->hook_root = "acme_plugin_$suffix";
}
#[After]
diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php
index dcba6a5..915a5ab 100644
--- a/tests/Integration/Support/consumer-request.php
+++ b/tests/Integration/Support/consumer-request.php
@@ -44,16 +44,16 @@ static function ( int $level, string $message, string $file, int $line ) use ( $
}
if ( array_key_exists( 'relaxation', $spec ) ) {
- add_filter( 'acme_ca/exports/should_restrict', static fn (): mixed => $spec['relaxation'] );
+ add_filter( 'acme_consumer_a/exports/should_restrict', static fn (): mixed => $spec['relaxation'] );
}
$acme_record_boots = static function (): void {
$consumers = array(
- 'consumer_a' => 'acme_ca',
- 'consumer_b' => 'acme_cb',
- 'persona_core' => 'acme_pc',
+ 'consumer_a' => array( 'acme_ca', 'acme_consumer_a' ),
+ 'consumer_b' => array( 'acme_cb', 'acme_consumer_b' ),
+ 'persona_core' => array( 'acme_pc', 'acme_persona_core' ),
);
- foreach ( $consumers as $key => $prefix ) {
+ foreach ( $consumers as $key => list( $prefix, $hook_root ) ) {
try {
$plugin = function_exists( "{$prefix}_plugin" ) ? ( "{$prefix}_plugin" )() : null;
} catch ( Error ) { // The composition root's class file may be missing.
@@ -62,7 +62,7 @@ static function ( int $level, string $message, string $file, int $line ) use ( $
$GLOBALS['acme_outcome']['records'][ $key ] = array(
'status' => $plugin?->boundary->status,
'skipped' => $plugin?->kernel?->skipped,
- 'boot_succeeded' => did_action( "$prefix/boot/succeeded" ),
+ 'boot_succeeded' => did_action( "$hook_root/boot/succeeded" ),
'functions_loaded' => function_exists( "{$prefix}_plugin" ),
'migrations' => is_object( $plugin ) && property_exists( $plugin, 'migrations' ) && is_object( $plugin->migrations ) ? get_class( $plugin->migrations ) : null,
);
@@ -139,7 +139,7 @@ static function (): never {
$records['export_error'] = $exception->getMessage();
}
$records['can_export_after_throw'] = current_user_can( $acme_capability );
- $records['relaxation_left'] = has_filter( 'acme_ca/exports/should_restrict', '__return_false' );
+ $records['relaxation_left'] = has_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' );
$records['safeguard_attached_after_throw'] = has_filter( 'map_meta_cap', 'acme_ca_map_export_capability' );
}
},
diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
index af32e73..52dc75e 100644
--- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
+++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
@@ -55,7 +55,7 @@ public static function silent_and_worded_rejections(): array {
public function test_the_box_is_added_on_add_meta_boxes(): void {
$host = new OrderMetaBox();
- new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
self::assertSame( 10, has_action( 'add_meta_boxes', array( $host, 'add_meta_box' ) ) );
}
@@ -195,14 +195,14 @@ public function test_per_object_fields_receive_the_order_and_contributed_fields_
$box = $this->box( self::document( $request['body'] ) );
self::assertContains( array( 'fields', $order->get_id() ), $request['records'] );
- self::assertContains( array( 'contributed', $order->get_id() ), $request['records'] );
+ self::assertContains( array( 'contributed', $order->get_id(), $this->id ), $request['records'] );
$this->control( $box, 'name' );
$this->control( $box, 'extra' );
}
public function test_the_box_saves_before_woocommerces_order_data(): void {
$host = new OrderMetaBox();
- new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
self::assertSame( 35, has_action( 'woocommerce_process_shop_order_meta', array( $host, 'save_meta_box' ) ) );
}
@@ -328,6 +328,7 @@ public function test_a_user_without_the_forms_capability_stores_nothing(): void
public function test_an_order_saved_in_code_stores_nothing(): void {
$order = $this->make_order( array( "_{$this->prefix}_name" => 'kept' ) );
$form = new ObjectForm(
+ $this->slug,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -337,7 +338,7 @@ public function test_an_order_saved_in_code_stores_nothing(): void {
)
),
new OrderMetaBox(),
- new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
@@ -400,6 +401,7 @@ public function test_on_the_posts_table_a_rejection_shows_after_the_redirect():
protected function spec( array $fields, array $form = array(), array $extra = array() ): array {
return $extra + array(
'id' => $this->id,
+ 'slug' => $this->slug,
'host' => 'order',
'host_args' => array( 'order_types' => array( 'shop_order' ) ),
'form' => $form + array(
diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
index 76f73d5..a678581 100644
--- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
+++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
@@ -66,19 +66,19 @@ public static function multi_value_selections(): array {
public function test_an_invalid_tab_id_is_rejected( string $id ): void {
$this->expectException( InvalidArgumentException::class );
- new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) );
+ new ProductDataTab( $this->slug, $id, new RecordedDefinition( static fn (): ?array => null ) );
}
#[DataProvider( 'v1_helper_ids' )]
public function test_every_v1_key_is_a_valid_tab_id( string $id ): void {
$this->expectNotToPerformAssertions();
- new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) );
+ new ProductDataTab( $this->slug, $id, new RecordedDefinition( static fn (): ?array => null ) );
}
public function test_the_definition_is_evaluated_only_when_the_edit_screen_needs_it(): void {
$definition = new RecordedDefinition( static fn (): ?array => null );
- $tab = new ProductDataTab( $this->prefix, $definition );
+ $tab = new ProductDataTab( $this->slug, $this->prefix, $definition );
$tab->register_hooks();
@@ -443,7 +443,7 @@ public function test_the_definition_receives_the_tabs_id_and_each_product_once()
'groups' => array(),
)
);
- $tab = new ProductDataTab( $this->prefix, $definition );
+ $tab = new ProductDataTab( $this->slug, $this->prefix, $definition );
$GLOBALS['product_object'] = $product;
try {
@@ -488,7 +488,7 @@ public function test_a_group_contributed_through_the_groups_filter_renders_for_t
$request = $this->edit_screen( $spec, $product->get_id() );
self::assertSame( 'contributed', $this->control( self::document( $request['body'] ), 'extra' )->getAttribute( 'value' ) );
- self::assertSame( array( array( 'groups', $product->get_id() ) ), $request['records'] );
+ self::assertSame( array( array( 'groups', $product->get_id(), $this->prefix ) ), $request['records'] );
}
public function test_a_new_product_renders_the_tab_with_its_defaults(): void {
@@ -511,7 +511,7 @@ public function test_labels_and_values_are_escaped(): void {
}
public function test_registering_hooks_the_save_onto_the_product_woocommerce_saves(): void {
- $tab = new ProductDataTab( $this->prefix, new RecordedDefinition( static fn (): ?array => null ) );
+ $tab = new ProductDataTab( $this->slug, $this->prefix, new RecordedDefinition( static fn (): ?array => null ) );
$tab->register_hooks();
@@ -875,7 +875,7 @@ public function test_a_group_contributed_through_the_groups_filter_saves_for_the
self::assertSame( array( 'v!' ), $this->meta( $product, 'extra' ) );
self::assertSame(
array(
- array( 'groups', $product->get_id() ),
+ array( 'groups', $product->get_id(), $this->prefix ),
array( 'validate', "_{$this->prefix}_extra", 'v' ),
),
$saved['records']
@@ -905,6 +905,7 @@ public function test_a_new_product_saves_its_fields_when_published(): void {
protected function spec( array $groups, array $extra = array() ): array {
return array(
'id' => $this->prefix,
+ 'slug' => $this->slug,
'label' => 'Acme',
'groups' => $groups,
) + $extra;
diff --git a/tests/Php74/Support/php74-request.php b/tests/Php74/Support/php74-request.php
index 70ea631..35aa505 100644
--- a/tests/Php74/Support/php74-request.php
+++ b/tests/Php74/Support/php74-request.php
@@ -9,7 +9,7 @@
wp_set_current_user( 1 );
set_current_screen( 'plugins' );
-add_filter( 'acme_ca/exports/should_restrict', '__return_false' );
+add_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' );
ob_start();
do_action( 'admin_notices' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Fires WordPress's own action.
From dafd2b3a4718a7b0f724588ae2b55f2249411237 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:18:17 +0100
Subject: [PATCH 03/23] Root the framework's hooks in the plugin's slug
Every hook the framework fired for a plugin took its first level from
the storage prefix, or from an id or option name the plugin passed, so
the hooks other code listens to changed whenever a plugin renamed its
stored keys, and one plugin's hooks read under as many roots as it had
surfaces. The updater's filter named the framework's vendor outright.
PluginBoot, AdminNotices, MigrationRunner, OptionsPage, ObjectForm and
ProductDataTab now take the plugin's slug and fire every hook under it
with underscores for its dashes. The three surfaces pass their id as
the filters' last argument, so a listener for one instance compares
the id. AdminNotices registers its routes under {slug}/v1, and the
updater filters {text domain}/github_updater/config. The prefix, the
option and the ids keep naming what is stored, nonces, script handles
and DOM ids. The slug is not checked at runtime, since a malformed one
only misnames hooks, which the platform tolerates.
The runner's hook no longer comes from its option, so the option is
checked against its longest stored name, {option}_high_water, where
the scheduled hook's own check used to bound it.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 6 ++---
packages/bootstrap/README.md | 2 +-
packages/bootstrap/src/Updater.php | 4 +--
.../core/src/AdminNotice/AdminNotices.php | 25 ++++++++++++++-----
.../core/src/Migration/MigrationRunner.php | 10 +++++---
packages/core/src/PluginBoot.php | 23 +++++++++++++----
.../settings/src/AdminPage/OptionsPage.php | 9 ++++---
.../settings/src/EditScreen/ObjectForm.php | 7 ++++--
.../src/ProductData/ProductDataTab.php | 7 ++++--
9 files changed, 66 insertions(+), 27 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 20bdf64..2122b5b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -79,7 +79,7 @@ actionlint && zizmor .github/
2. **Nodes and the container.** Node classes never extend, implement or `use` optional or late-loaded types. Never constructor-inject a gated component; inject ungated services. Every injected service that implements `ComponentInterface` is also a node, because only nodes run `register_hooks()`. Definitions are static closures, taking `ContainerInterface $c` only when they read the container, or autowiring, never PHP-DI helpers. Hold the built container as `ContainerInterface`. Container reads belong in the kernel and composition wiring: a class-entry read checks the entry's type before use, and a factory collecting contributors re-applies their `should_load()` before resolving them. Framework classes take their logger injected; the composition root chooses the logger. A failure is logged with its Throwable under `exception`, a `WP_Error`'s Throwable data included. The uninstall engine runs without a composition root, so it writes the jobs it could not unschedule to PHP's error log, one line per site.
3. **Constructors and `register_hooks()`.** Constructors take services only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation.
4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the project controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type.
-5. **Safeguards.** Everything a safeguard calls lives in `includes/safeguards.php` or files it requires. The entry loads that file before `load.php` and the floor gate, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, or guards them. Relaxation happens only through the safeguard's own filter, after `{prefix}/boot/succeeded`, on an explicit `false`.
+5. **Safeguards.** Everything a safeguard calls lives in `includes/safeguards.php` or files it requires. The entry loads that file before `load.php` and the floor gate, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, or guards them. Relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`.
6. **Shared logic.** `src/` holds a plugin's composition root and its components. Code that registers no hooks and is not part of the tree, such as option keys and readers, the footprint, migration steps and asset registrars, is prefixed functions and constants in `includes/`, which `functions.php` loads. Per-request state lives in a static holder or `WC()->session`. No service locator.
7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant.
8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components store data only under the consumer's `{prefix}_`, so the plugin's footprint covers it; a `TransientCache` prefix and any generation option it names start with it, so the footprint covers the generation option, and its entries, transients that all carry a timeout, expire on their own. An `ObjectCache` group starts with it too, which keeps it apart from core's and other plugins' groups. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`.
@@ -100,7 +100,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships
- No package ships free functions or a Composer `files` entry. A stateless function family is a final class with public static entry points, protected static helpers and a protected constructor. An operation that carries state across its steps is an object, and constant data is a constant. On a final class, a protected helper with a single call site is inlined, unless it is a `match` target, a hook callback or an override, or carries a `@phpstan-assert*` tag. Logic several classes share has one implementation: when its callers are in one package, an `@internal` final collaborator or an `@internal` public static on the class that owns the concept, and when they span packages, the smallest public API in the lowest package that needs it. A protected copy needs the owner's ruling. A lone call to a WordPress, WooCommerce or PHP API is not a copy, and neither is an idiom WPCS needs to see at each call site; the framework knowledge wrapped around such a call is. A copy also stays where no single home loads for every caller, as between PHP 7.4 by-path code and autoloaded code. The by-path classes register hooks with static closures, which bind each registration without static state, and a failed boot or boot check hooks its notice as a static closure; every other hook callback is a public named method. A framework method takes a callable only for consumer code it runs inside its own envelope, as the boot composition, migration steps and the uninstall cleanup, or for per-field logic in field arrays, and spells it `callable`; a helper's definition comes from a definition object, and an envelope inside the framework is written inline, with the rule it applies in one home.
- A name imported from `Psr\` takes a `Psr` alias, as in `use Psr\Log\LoggerInterface as PsrLoggerInterface;`, so every use shows that the type is a PSR interface or class. Only PHP's own functions and constants take a leading `\`, which lets opcache compile the functions it special-cases; WordPress, WooCommerce and Action Scheduler functions and constants are unqualified, and classes are imported or fully qualified.
- Every element outside `tests/` has a docblock: one third-person sentence naming the role, such as "Returns …", plus a second paragraph only for a non-obvious why. Property and constant docblocks open with "The …", constructors read "Constructor.", and an override reads `{@inheritDoc}`. Every other function and method docblock ends with `@return`, `@return void` included. Tag values align at column 10, a file docblock's `@package` sits in its own group after `@since` and `@version`, a function or method that declares a `global` documents it with `@global` in its own group between `@version` and `@param`, each `@param` stays on one line, `@throws` reads "Thrown when …", `@since` names the release that added the element, and `@version` the last release that changed its behavior or signature.
-- A hook name is `{prefix}/area/name`, with `/` between levels and `_` between words, and its last level says what kind of hook it is, because WordPress tells actions from filters by no name form: a past participle for an event (`{prefix}/boot/succeeded`), `before_` or `after_` for a position, an imperative for a scheduled or command hook (`run`), a predicate (`is_`, `has_`, `can_`, `should_`) for a boolean filter (`{prefix}/admin_notice/should_show`), and the noun of the value for any other filter, plural for a list. A hook built from an id or option name the consumer passes takes it whole as its first level, `{id}/{surface}/{leaf}` (`{id}/object_form/fields`, `{option}/migration/run`), because the id also names a nonce, an option or a DOM id, and any other dynamic part goes last (`dws_github_updater/config/{slug}`).
+- A hook name is `{root}/area/name`, its root the plugin's slug with underscores for its dashes, with `/` between levels and `_` between words, and its last level says what kind of hook it is, because WordPress tells actions from filters by no name form: a past participle for an event (`{root}/boot/succeeded`), `before_` or `after_` for a position, an imperative for a scheduled or command hook (`run`), a predicate (`is_`, `has_`, `can_`, `should_`) for a boolean filter (`{root}/admin_notice/should_show`), and the noun of the value for any other filter, plural for a list. A hook that a surface fires for each of its instances passes the instance's id as its last argument (`{root}/object_form/fields`), and any other dynamic part of a name goes last.
- Every `do_action()` and `apply_filters()` with a public hook name carries a WordPress hook docblock directly above the call: "Fires …" or "Filters …", `@since`, `@version` and one `@param` per argument, which PHPStan checks against the call's arguments.
- Classes and traits outside `tests/` group members in `// region NAME` blocks named, in order, TRAITS, FIELDS AND CONSTANTS, MAGIC METHODS, GETTERS, SETTERS, INHERITED METHODS, METHODS, FACTORY METHODS, HOOKS and HELPERS; exception classes, interfaces and enums carry none.
- Inline comments state a non-obvious why in one capitalized sentence, and every file describes the present, never the change. A `phpcs:ignore` trails its line, names the sniff and gives a one-clause reason; a `@phpstan-ignore` names its identifier.
@@ -111,7 +111,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`. Components gated on the data revision 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.
+1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{root}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Components gated on the data revision 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. 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 cancel Action Scheduler's default store cannot write repeats until the request's time limit; 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.
diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md
index 21b7ff8..d8db35e 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`. The `dws_github_updater/config/{text-domain}` filter receives and returns `enabled`, `repository` as `owner/name`, `asset` and `channel`: an installed version with a suffix follows the `prerelease` channel, the highest of the ten newest releases, and any other version the `stable` channel, the latest release. 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()` misorders other suffixes, such as `-pre`, `-patch` or `-Beta`.
+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`. The `{text_domain}/github_updater/config` filter, named after the text domain with underscores for its dashes, such as `acme_persona_bootstrap/github_updater/config`, receives and returns `enabled`, `repository` as `owner/name`, `asset` and `channel`: an installed version with a suffix follows the `prerelease` channel, the highest of the ten newest releases, and any other version the `stable` channel, the latest release. 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()` misorders other suffixes, such as `-pre`, `-patch` or `-Beta`.
`Requirements::print_admin_notice()` prints a notice to users who can activate plugins, as `wp_admin_notice()` prints it, so a plugin can report a problem before it boots:
diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php
index 7e557bb..e347ec3 100644
--- a/packages/bootstrap/src/Updater.php
+++ b/packages/bootstrap/src/Updater.php
@@ -150,7 +150,7 @@ protected static function get_updater_config( string $plugin_file ): ?array {
);
/**
- * Filters the GitHub updater's configuration for the plugin whose text domain ends the hook name. A key the filter leaves out keeps its default.
+ * Filters the GitHub updater's configuration for the plugin whose text domain, in snake case, roots the hook name. A key the filter leaves out keeps its default.
*
* @since 2.0.0
* @version 2.0.0
@@ -158,7 +158,7 @@ protected static function get_updater_config( string $plugin_file ): ?array {
* @param array{enabled: bool, repository: string, asset: string, channel: 'stable'|'prerelease'} $config Whether updates are offered, the owner/name repository, the release asset's file name, and the channel: 'stable' follows the latest release, 'prerelease' the highest of the ten newest releases.
* @param string $plugin_basename The plugin's basename.
*/
- $config = apply_filters( "dws_github_updater/config/$slug", $defaults, plugin_basename( $plugin_file ) ); // @phpstan-ignore WPCompat.parameterNotAvailable.applyfilters.args (WordPress 6.0 only formalized the arguments that older versions already passed on.)
+ $config = apply_filters( \str_replace( '-', '_', $slug ) . '/github_updater/config', $defaults, plugin_basename( $plugin_file ) ); // @phpstan-ignore WPCompat.parameterNotAvailable.applyfilters.args (WordPress 6.0 only formalized the arguments that older versions already passed on.)
if ( ! \is_array( $config ) ) {
return null;
}
diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php
index fb1f7f8..ef01921 100644
--- a/packages/core/src/AdminNotice/AdminNotices.php
+++ b/packages/core/src/AdminNotice/AdminNotices.php
@@ -13,7 +13,7 @@
/**
* Keeps a plugin's admin notices, shows them in the admin, the post editor included, and lets users dismiss them through a REST endpoint.
*
- * The prefix names the plugin's storage keys, hooks and REST namespace. Each scope keeps its notices as one array by id: site notices in the {prefix}_notices option, network notices in the {prefix}_network_notices network option, user notices in the {prefix}_notices user option, and request notices in the instance, so components that add them must share it. A viewer's dismissals, and the one-shots shown to them that others still see, are kept in their {prefix}_hidden_notices user option, or for network notices in their {prefix}_network_hidden_notices user meta, keyed by network.
+ * The slug names the plugin's REST namespace and, in snake case, roots its hooks, while the prefix names its storage keys. Each scope keeps its notices as one array by id: site notices in the {prefix}_notices option, network notices in the {prefix}_network_notices network option, user notices in the {prefix}_notices user option, and request notices in the instance, so components that add them must share it. A viewer's dismissals, and the one-shots shown to them that others still see, are kept in their {prefix}_hidden_notices user option, or for network notices in their {prefix}_network_hidden_notices user meta, keyed by network.
*
* @since 2.0.0
* @version 2.0.0
@@ -71,6 +71,16 @@ final class AdminNotices implements ComponentInterface {
*/
protected bool $needs_dismissal_script = false;
+ /**
+ * The root of the plugin's hook names.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var string
+ */
+ protected string $hook_root;
+
// endregion
// region MAGIC METHODS
@@ -81,12 +91,14 @@ final class AdminNotices implements ComponentInterface {
* @since 2.0.0
* @version 2.0.0
*
+ * @param non-empty-string $slug The plugin's slug, which names its REST namespace and whose dashes become underscores in the root of its hook names.
* @param string $prefix The plugin's prefix: a lowercase letter followed by up to 31 lowercase letters, digits and underscores, with no network or hidden segment.
* @param PsrLoggerInterface $logger The logger that records failures to read, write or filter notices.
*
* @throws InvalidArgumentException Thrown when the prefix is invalid.
*/
public function __construct(
+ protected string $slug,
protected string $prefix,
protected PsrLoggerInterface $logger
) {
@@ -94,7 +106,8 @@ public function __construct(
throw new InvalidArgumentException( "Notices prefix '$prefix' is not a lowercase letter followed by up to 31 lowercase letters, digits and underscores, or it has a 'network' or 'hidden' segment." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
- $this->logger = new GuardedLogger( $logger, $prefix );
+ $this->hook_root = \str_replace( '-', '_', $slug );
+ $this->logger = new GuardedLogger( $logger, $prefix );
}
// endregion
@@ -453,7 +466,7 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP
* @param int $user_id The user who dismissed it.
* @param int $owner The notice's owner: 0 for a request notice, otherwise the user, blog or network id.
*/
- do_action( "$this->prefix/admin_notice/dismissed", $id, $scope->value, $user_id, $owner );
+ do_action( "$this->hook_root/admin_notice/dismissed", $id, $scope->value, $user_id, $owner );
}
return new \WP_REST_Response( array( 'changed' => $changed ) );
@@ -550,7 +563,7 @@ protected function select( bool $block_editor ): array {
* @param string $id The notice's id.
* @param string $scope The notice's scope.
*/
- if ( true === apply_filters( "$this->prefix/admin_notice/should_show", true, $notice->id, $scope->value ) ) {
+ if ( true === apply_filters( "$this->hook_root/admin_notice/should_show", true, $notice->id, $scope->value ) ) {
$selected[] = $notice;
}
}
@@ -802,7 +815,7 @@ protected function get_message( Notice $notice ): string {
* @param string $id The notice's id.
* @param string $scope The notice's scope.
*/
- $message = apply_filters( "$this->prefix/admin_notice/message", $notice->message, $notice->id, $notice->scope->value );
+ $message = apply_filters( "$this->hook_root/admin_notice/message", $notice->message, $notice->id, $notice->scope->value );
return \is_string( $message ) ? $message : $notice->message;
}
@@ -852,7 +865,7 @@ protected function get_dom_id( Notice $notice ): string {
* @return non-falsy-string
*/
protected function get_rest_namespace(): string {
- return \str_replace( '_', '-', $this->prefix ) . '/v1';
+ return "$this->slug/v1";
}
/**
diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php
index e3d2563..cf1067d 100644
--- a/packages/core/src/Migration/MigrationRunner.php
+++ b/packages/core/src/Migration/MigrationRunner.php
@@ -103,17 +103,19 @@ final class MigrationRunner implements ComponentInterface {
* @version 2.0.0
*
* @param string $plugin_file The path to the plugin's entry file.
- * @param string $option The option that stores the data revision, and the prefix of every other option and hook the runner owns.
+ * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the runner's hook.
+ * @param string $option The option that stores the data revision, and the prefix of every other option the runner owns.
* @param list<(callable(): void)|BatchedMigrationStepInterface> $steps The migration steps, append-only, where index 0 brings the data to revision 1.
* @param PsrLoggerInterface $logger The logger that records failures and warnings.
* @param SchedulerInterface $scheduler The scheduler that runs the runner's hook in the background.
* @param int $batch_time_limit Optional. The seconds, from 1, that a request spends on steps and batches before it pauses. Default is 20.
* @param PsrClockInterface|null $clock Optional. The clock. Default is the system clock.
*
- * @throws InvalidArgumentException Thrown when the option name cannot name the lock or the scheduled hook.
+ * @throws InvalidArgumentException Thrown when the option cannot prefix every option the runner owns, or the slug cannot root the scheduled hook.
*/
public function __construct(
protected string $plugin_file,
+ protected string $slug,
protected string $option,
protected array $steps,
protected PsrLoggerInterface $logger,
@@ -121,6 +123,8 @@ public function __construct(
protected int $batch_time_limit = 20,
?PsrClockInterface $clock = null
) {
+ OptionRows::check_name( "{$option}_high_water" ); // The longest option the runner owns.
+
$this->clock = $clock ?? new SystemClock();
$this->lock = new Lock( "{$option}_lock", self::LOCK_TTL, $this->clock );
$this->scheduled_hook = new ScheduledHook( $this->get_hook() );
@@ -580,7 +584,7 @@ protected function get_time(): float {
* @return non-empty-string
*/
protected function get_hook(): string {
- return "$this->option/migration/run";
+ return \str_replace( '-', '_', $this->slug ) . '/migration/run';
}
// endregion
diff --git a/packages/core/src/PluginBoot.php b/packages/core/src/PluginBoot.php
index cd66d8a..09fa87a 100644
--- a/packages/core/src/PluginBoot.php
+++ b/packages/core/src/PluginBoot.php
@@ -27,6 +27,16 @@ final class PluginBoot {
*/
protected(set) string $status = 'pending';
+ /**
+ * The root of the plugin's hook names.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var string
+ */
+ protected string $hook_root;
+
// endregion
// region MAGIC METHODS
@@ -38,15 +48,18 @@ final class PluginBoot {
* @version 2.0.0
*
* @param string $plugin_file The path to the plugin's entry file.
- * @param string $prefix The prefix of the plugin's hook names, transients and log channel.
+ * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of its hook names.
+ * @param string $prefix The prefix of the plugin's transients and log channel.
* @param PsrLoggerInterface $logger The logger that records boot failures.
*/
public function __construct(
protected string $plugin_file,
+ string $slug,
protected string $prefix,
protected PsrLoggerInterface $logger
) {
- $this->logger = new GuardedLogger( $logger, $prefix );
+ $this->hook_root = \str_replace( '-', '_', $slug );
+ $this->logger = new GuardedLogger( $logger, $prefix );
}
// endregion
@@ -108,7 +121,7 @@ static function () use ( $plugin_file ): void {
*
* @param \Throwable $exception The failure that stopped the composition.
*/
- do_action( "$this->prefix/boot/failed", $exception );
+ do_action( "$this->hook_root/boot/failed", $exception );
if ( self::is_development_environment() ) {
throw $exception;
}
@@ -125,7 +138,7 @@ static function () use ( $plugin_file ): void {
* @since 2.0.0
* @version 2.0.0
*/
- do_action( "$this->prefix/boot/blocked" );
+ do_action( "$this->hook_root/boot/blocked" );
return;
}
@@ -137,7 +150,7 @@ static function () use ( $plugin_file ): void {
* @since 2.0.0
* @version 2.0.0
*/
- do_action( "$this->prefix/boot/succeeded" );
+ do_action( "$this->hook_root/boot/succeeded" );
}
/**
diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php
index 3a55504..6cf13b4 100644
--- a/packages/settings/src/AdminPage/OptionsPage.php
+++ b/packages/settings/src/AdminPage/OptionsPage.php
@@ -43,12 +43,14 @@ final class OptionsPage {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $id The page's menu slug, which also prefixes its tabs filter.
+ * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the page's tabs filter.
+ * @param string $id The page's menu slug.
* @param OptionsPageDefinitionInterface $definition The page's definition.
*
* @throws InvalidArgumentException Thrown when the id is not a valid key.
*/
public function __construct(
+ protected string $slug,
protected string $id,
protected OptionsPageDefinitionInterface $definition
) {
@@ -242,9 +244,10 @@ protected function get_page(): array {
* @since 2.0.0
* @version 2.0.0
*
- * @param list $tabs The page's tabs.
+ * @param list $tabs The page's tabs.
+ * @param string $page_id The page's menu slug.
*/
- $page['tabs'] = apply_filters( "$this->id/options_page/tabs", $page['tabs'] );
+ $page['tabs'] = apply_filters( \str_replace( '-', '_', $this->slug ) . '/options_page/tabs', $page['tabs'], $this->id );
$this->page = $page;
}
diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php
index 00558c4..ae9eba5 100644
--- a/packages/settings/src/EditScreen/ObjectForm.php
+++ b/packages/settings/src/EditScreen/ObjectForm.php
@@ -64,7 +64,8 @@ final class ObjectForm {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $id The form's id, which starts with the plugin's prefix and names the form's controls, nonce and fields filter.
+ * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the form's fields filter.
+ * @param string $id The form's id, which starts with the plugin's prefix and names the form's controls and nonce.
* @param ObjectFormDefinitionInterface $definition The form's definition.
* @param ObjectFormHostInterface $host The host that shows and saves the form.
* @param AdminNotices $notices The plugin's admin notices, which report the rejections.
@@ -73,6 +74,7 @@ final class ObjectForm {
* @throws InvalidArgumentException Thrown when the id is not a valid key.
*/
public function __construct(
+ protected string $slug,
protected string $id,
protected ObjectFormDefinitionInterface $definition,
protected ObjectFormHostInterface $host,
@@ -261,8 +263,9 @@ protected function get_form( int $object_id ): array {
*
* @param list $fields The form's fields for the object.
* @param int $object_id The id of the object being edited or saved.
+ * @param string $form_id The form's id.
*/
- $form['fields'] = apply_filters( "$this->id/object_form/fields", $form['fields'], $object_id );
+ $form['fields'] = apply_filters( \str_replace( '-', '_', $this->slug ) . '/object_form/fields', $form['fields'], $object_id, $this->id );
$this->forms[ $object_id ] = $form;
}
diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php
index 147c816..d037079 100644
--- a/packages/woocommerce/src/ProductData/ProductDataTab.php
+++ b/packages/woocommerce/src/ProductData/ProductDataTab.php
@@ -42,12 +42,14 @@ final class ProductDataTab {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $id The tab's key among the product data tabs, which also prefixes its panel's HTML id and its groups filter.
+ * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the tab's groups filter.
+ * @param string $id The tab's key among the product data tabs, which also prefixes its panel's HTML id.
* @param ProductDataTabDefinitionInterface $definition The definition, which the tab asks for once per product, on first use after init.
*
* @throws InvalidArgumentException Thrown when the id is not a valid key.
*/
public function __construct(
+ protected string $slug,
protected string $id,
protected ProductDataTabDefinitionInterface $definition
) {
@@ -217,8 +219,9 @@ protected function get_groups( array $tab, \WC_Product $product ): array {
*
* @param list $groups The tab's groups.
* @param \WC_Product $product The product being edited or saved.
+ * @param string $tab_id The tab's key among the product data tabs.
*/
- return apply_filters( "$this->id/product_data_tab/groups", $tab['groups'], $product );
+ return apply_filters( \str_replace( '-', '_', $this->slug ) . '/product_data_tab/groups', $tab['groups'], $product, $this->id );
}
/**
From 33969497f843ab4c33d84f307f9c29b2999ba7b0 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:18:28 +0100
Subject: [PATCH 04/23] Expect core to register a plugin's built assets
Each plugin carried its own copy of three registrars that read the
*.asset.php file wp-scripts writes beside a build, differing only in
prefix, so a fix to how a build's dependencies, translations or RTL
stylesheet register had to be made in every copy by hand.
The rows build core's Assets with the core fixture's entry file and a
slug, and expect a script registered with the build's script handles
but not its module imports, its version, its loading arguments and its
translations under the slug from the plugin's languages/ directory; a
style registered with the build's version and its RTL variant; and a
handle another plugin registered first left as it was. The fixture
commits the build's asset file, so no build runs in the tests.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Core/assets/build/editor.asset.php | 13 +++++
tests/Integration/Core/Asset/AssetsTest.php | 52 +++++++++++++++++++
2 files changed, 65 insertions(+)
create mode 100644 tests/Fixtures/Core/assets/build/editor.asset.php
create mode 100644 tests/Integration/Core/Asset/AssetsTest.php
diff --git a/tests/Fixtures/Core/assets/build/editor.asset.php b/tests/Fixtures/Core/assets/build/editor.asset.php
new file mode 100644
index 0000000..72a35b3
--- /dev/null
+++ b/tests/Fixtures/Core/assets/build/editor.asset.php
@@ -0,0 +1,13 @@
+ array(
+ 'wp-blocks',
+ array(
+ 'id' => '@wordpress/interactivity',
+ 'import' => 'static',
+ ),
+ 'wp-i18n',
+ ),
+ 'version' => 'a1b2c3',
+);
diff --git a/tests/Integration/Core/Asset/AssetsTest.php b/tests/Integration/Core/Asset/AssetsTest.php
new file mode 100644
index 0000000..f4e2179
--- /dev/null
+++ b/tests/Integration/Core/Asset/AssetsTest.php
@@ -0,0 +1,52 @@
+register_script( self::SCRIPT, 'assets/build/editor.js', array( 'in_footer' => true ) ) );
+
+ $script = wp_scripts()->registered[ self::SCRIPT ];
+ self::assertSame( plugin_dir_url( self::PLUGIN_FILE ) . 'assets/build/editor.js', $script->src );
+ self::assertSame( array( 'wp-blocks', 'wp-i18n' ), $script->deps );
+ self::assertSame( 'a1b2c3', $script->ver );
+ self::assertSame( 1, $script->extra['group'] ?? null );
+ self::assertSame( 'acme-plugin', $script->textdomain );
+ self::assertSame( plugin_dir_path( self::PLUGIN_FILE ) . 'languages', $script->translations_path );
+ }
+
+ public function test_a_style_registers_with_its_build_s_version_and_a_right_to_left_variant(): void {
+ self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_style( self::STYLE, 'assets/build/editor.css' ) );
+
+ $style = wp_styles()->registered[ self::STYLE ];
+ self::assertSame( plugin_dir_url( self::PLUGIN_FILE ) . 'assets/build/editor.css', $style->src );
+ self::assertSame( array(), $style->deps );
+ self::assertSame( 'a1b2c3', $style->ver );
+ self::assertSame( 'replace', $style->extra['rtl'] ?? null );
+ }
+
+ public function test_a_handle_registered_elsewhere_keeps_its_script_and_gains_no_translations(): void {
+ wp_register_script( self::SCRIPT, 'https://example.com/other.js', array(), '1', true );
+
+ self::assertFalse( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_script( self::SCRIPT, 'assets/build/editor.js' ) );
+ self::assertSame( 'https://example.com/other.js', wp_scripts()->registered[ self::SCRIPT ]->src );
+ self::assertNull( wp_scripts()->registered[ self::SCRIPT ]->textdomain );
+ }
+}
From cf349999e950c022d61674388440528a107f90ea Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:18:28 +0100
Subject: [PATCH 05/23] Register a plugin's built assets through core
The registrars that read a build's *.asset.php lived as prefixed
globals in each plugin, three copies of one WordPress-facing service.
Core's final Assets takes the plugin's entry file and slug. Its
register_script() registers the built script with the build's script
dependencies, dropping script module imports, which are not script
handles, and its version, and sets the script's translations under the
slug from the plugin's languages/ directory; register_style() registers
the stylesheet with its version and its RTL variant. Both report
whether WordPress registered the handle, and a handle registered
elsewhere first gains no translations. A composition root defines it
once in its container, and the core README describes it.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 1 +
packages/core/README.md | 2 +
packages/core/src/Asset/Assets.php | 105 +++++++++++++++++++++++++++++
packages/core/src/Asset/index.php | 1 +
4 files changed, 109 insertions(+)
create mode 100644 packages/core/src/Asset/Assets.php
create mode 100644 packages/core/src/Asset/index.php
diff --git a/AGENTS.md b/AGENTS.md
index 2122b5b..9794f30 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,6 +16,7 @@ wordpress-framework/
├── packages/core/ ahegyes/wp-framework-core `require.php`, ext-filter · bootstrap ^2.0, shared ^2.0, psr/clock ^1, psr/container ^2.0.2, psr/log ^3.0.2, psr/simple-cache ^3
│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot (is_development_environment (@internal)), ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface
│ ├── AdminNotice/{AdminNotices, Notice, NoticeType, NoticeScope}
+│ ├── Asset/Assets
│ ├── Caching/{TransientCache, ObjectCache, CacheEntries (@internal), Exceptions/InvalidCacheKeyException}
│ ├── Clock/SystemClock · Logging/{AbstractLogger, ErrorLogLogger, GuardedLogger (@internal)}
│ ├── Migration/{MigrationRunner, BatchedMigrationStepInterface}
diff --git a/packages/core/README.md b/packages/core/README.md
index c70aa6c..4566fca 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -192,6 +192,8 @@ final class Greeting implements ComponentInterface {
}
```
+`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $plugin_file, $slug )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant.
+
The plugin's `uninstall.php` hands its footprint to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data:
```php
diff --git a/packages/core/src/Asset/Assets.php b/packages/core/src/Asset/Assets.php
new file mode 100644
index 0000000..e810215
--- /dev/null
+++ b/packages/core/src/Asset/Assets.php
@@ -0,0 +1,105 @@
+ $args Optional. How the script loads, as wp_register_script() takes it. Default is none, which loads it in the head.
+ *
+ * @return bool
+ */
+ public function register_script( string $handle, string $path, array $args = array() ): bool {
+ $asset = $this->get_asset( $path );
+
+ // Script modules the build imports are not script handles, so they stay out of the dependencies.
+ $registered = wp_register_script( $handle, plugin_dir_url( $this->plugin_file ) . $path, \array_values( \array_filter( $asset['dependencies'], 'is_string' ) ), $asset['version'], $args );
+ if ( $registered ) {
+ wp_set_script_translations( $handle, $this->slug, plugin_dir_path( $this->plugin_file ) . 'languages' );
+ }
+
+ return $registered;
+ }
+
+ /**
+ * Registers a built stylesheet with its build's version and its right-to-left variant, and returns whether WordPress registered it.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param non-empty-string $handle The stylesheet's handle.
+ * @param non-empty-string $path The built stylesheet's path, relative to the plugin's directory.
+ *
+ * @return bool
+ */
+ public function register_style( string $handle, string $path ): bool {
+ $registered = wp_register_style( $handle, plugin_dir_url( $this->plugin_file ) . $path, array(), $this->get_asset( $path )['version'] );
+ if ( $registered ) {
+ wp_style_add_data( $handle, 'rtl', 'replace' );
+ }
+
+ return $registered;
+ }
+
+ // endregion
+
+ // region HELPERS
+
+ /**
+ * Returns the dependencies and version that wp-scripts writes beside a built file.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param string $path The built file's path, relative to the plugin's directory.
+ *
+ * @return array{dependencies: list, version: string}
+ */
+ protected function get_asset( string $path ): array {
+ /**
+ * The build's dependencies and version.
+ *
+ * @var array{dependencies: list, version: string} $asset
+ */
+ $asset = require plugin_dir_path( $this->plugin_file ) . \dirname( $path ) . '/' . \pathinfo( $path, \PATHINFO_FILENAME ) . '.asset.php';
+ return $asset;
+ }
+
+ // endregion
+}
diff --git a/packages/core/src/Asset/index.php b/packages/core/src/Asset/index.php
new file mode 100644
index 0000000..f767346
--- /dev/null
+++ b/packages/core/src/Asset/index.php
@@ -0,0 +1 @@
+
Date: Thu, 12 Nov 2026 18:22:36 +0100
Subject: [PATCH 06/23] Expect consumer-a's safeguards in a class and one build
catch
Consumer-a's safeguard and its helper were named globals in includes/,
reachable and removable by any code, and its entry reported a build
that could not load through two more globals, also called from its
boot, so a composition root that could not be built showed the build
notice beside the boot boundary's own report.
The harness now finds the safeguard as a static closure that the
Safeguards class declares, at the same priority, and tells that the
safeguards loaded by that class. A missing src/Plugin.php is no longer
an incomplete build that the entry reports, so its build row goes; the
fatal at plugins_loaded that remains is the accepted cost of a partial
deploy that keeps the autoloader but loses a class. The uninstall row
that removed the footprint's include removes src/Options.php, where
the consent option's name lives.
Assisted-by: Claude Code:claude-opus-5-5
---
.../ConsumerA/ConsumerABuildTest.php | 1 -
.../ConsumerA/ConsumerAUninstallTest.php | 2 +-
.../Integration/Support/consumer-request.php | 21 +++++++++++++++----
3 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/tests/Integration/ConsumerA/ConsumerABuildTest.php b/tests/Integration/ConsumerA/ConsumerABuildTest.php
index d482e0e..7147225 100644
--- a/tests/Integration/ConsumerA/ConsumerABuildTest.php
+++ b/tests/Integration/ConsumerA/ConsumerABuildTest.php
@@ -20,7 +20,6 @@ public static function missing_build_files(): array {
'bootstrap-loader' => array( 'vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php', false ),
'autoloader' => array( 'vendor/autoload.php', true ),
'scoped-autoloader' => array( 'vendor-prefixed/scoper-autoload.php', true ),
- 'composition-root' => array( 'src/Plugin.php', false ),
);
}
diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php
index 9e79fa4..891b95d 100644
--- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php
+++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php
@@ -31,7 +31,7 @@ public static function engines_that_cannot_run(): array {
'scoped-autoloader' => array( array( 'vendor-prefixed/scoper-autoload.php' ), array() ),
'composition-root' => array( array( 'src/Plugin.php' ), array() ),
'scoped-engine' => array( array( 'vendor-prefixed/ahegyes/wp-framework-core/src/Uninstall/Uninstaller.php' ), array() ),
- 'footprint' => array( array( 'includes/footprint.php' ), array() ),
+ 'options' => array( array( 'src/Options.php' ), array() ),
'below-php-floor' => array( array(), array( 'Requires PHP' => '99' ) ),
);
}
diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php
index 915a5ab..c2937d8 100644
--- a/tests/Integration/Support/consumer-request.php
+++ b/tests/Integration/Support/consumer-request.php
@@ -47,6 +47,19 @@ static function ( int $level, string $message, string $file, int $line ) use ( $
add_filter( 'acme_consumer_a/exports/should_restrict', static fn (): mixed => $spec['relaxation'] );
}
+// Consumer-a's safeguard is a static closure, so it is found by the class that declares it rather than by name.
+$acme_safeguard_priority = static function (): int|false {
+ foreach ( $GLOBALS['wp_filter']['map_meta_cap']->callbacks ?? array() as $priority => $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ if ( $callback['function'] instanceof Closure && 'Acme\\ConsumerA\\Safeguards' === ( new ReflectionFunction( $callback['function'] ) )->getClosureScopeClass()?->getName() ) {
+ return $priority;
+ }
+ }
+ }
+
+ return false;
+};
+
$acme_record_boots = static function (): void {
$consumers = array(
'consumer_a' => array( 'acme_ca', 'acme_consumer_a' ),
@@ -100,7 +113,7 @@ static function () use ( $acme_capability ): void {
add_action(
'admin_init',
- static function () use ( $spec, $acme_capability, $acme_record_boots ): void {
+ static function () use ( $spec, $acme_capability, $acme_record_boots, $acme_safeguard_priority ): void {
$records = &$GLOBALS['acme_outcome']['records'];
if ( isset( $spec['uninstall'] ) ) {
$records['deleted'] = delete_plugins( array( $spec['uninstall'] ) );
@@ -115,12 +128,12 @@ static function () use ( $spec, $acme_capability, $acme_record_boots ): void {
$records += array(
'woocommerce_present' => class_exists( 'WooCommerce', false ),
'can_export' => current_user_can( $acme_capability ),
- 'safeguard_attached' => has_filter( 'map_meta_cap', 'acme_ca_map_export_capability' ),
+ 'safeguard_attached' => $acme_safeguard_priority(),
'export_hooked' => has_action( 'admin_post_acme_ca_export' ),
'reports_page_class' => class_exists( 'Acme\ConsumerA\Reports\ReportsPage', false ),
'updater_hooked' => has_filter( 'update_plugins_github.com' ),
'schedules_hooked' => has_filter( 'cron_schedules' ),
- 'safeguards_loaded' => function_exists( 'acme_ca_is_relaxed' ),
+ 'safeguards_loaded' => class_exists( 'Acme\\ConsumerA\\Safeguards', false ),
'unloaded_include' => function_exists( 'acme_ca_unloaded_include' ),
'greeting_shortcode' => shortcode_exists( 'acme_pc_greeting' ),
);
@@ -140,7 +153,7 @@ static function (): never {
}
$records['can_export_after_throw'] = current_user_can( $acme_capability );
$records['relaxation_left'] = has_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' );
- $records['safeguard_attached_after_throw'] = has_filter( 'map_meta_cap', 'acme_ca_map_export_capability' );
+ $records['safeguard_attached_after_throw'] = $acme_safeguard_priority();
}
},
PHP_INT_MAX
From 676c52422fcbeb9867a18801a240393850fda9e1 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:25:35 +0100
Subject: [PATCH 07/23] Move the reference plugins' internals into classes
Consumer-a and the core persona, the anatomy the template copies,
declared their option names, migration steps, footprint and safeguards
as prefixed globals in includes/, which any code could call and come
to rely on, and consumer-a reported an incomplete build through two
more globals that its boot also called.
Each plugin's settings option and defaults are constants of a final
Options class, which also holds the core persona's greeting reader;
its steps are protected statics of a final Migrations class, handed to
the runner as first-class callables; and its footprint is a constant
of its composition root, which uninstall.php reads after the
autoloader. Consumer-a's safeguard is a static closure of a final
Safeguards class at the plugin root, required by path before the floor
gate and parsed on PHP 7.4, so no code can remove it by name. Its entry
wraps everything after the safeguards in one try, whose catch rethrows
in local and development environments and otherwise hooks the build
notice, and its boot leaves a composition that cannot be built to the
boot boundary. The PHP 7.4 parse and the PHP 7.4 PHPStan profile cover
Safeguards.php in place of includes/safeguards.php, and convention 5
describes the class.
Assisted-by: Claude Code:claude-opus-5-5
---
.github/workflows/quality.yml | 2 +-
AGENTS.md | 2 +-
packages/core/README.md | 20 +++--
phpstan.bootstrap.neon | 4 +-
tests/Fixtures/consumer-a/Safeguards.php | 80 +++++++++++++++++++
tests/Fixtures/consumer-a/acme-consumer-a.php | 74 ++++++-----------
tests/Fixtures/consumer-a/functions.php | 8 +-
.../consumer-a/includes/footprint.php | 34 --------
.../consumer-a/includes/migrations.php | 43 ----------
.../consumer-a/includes/safeguards.php | 54 -------------
tests/Fixtures/consumer-a/phpstan.dist.neon | 1 +
tests/Fixtures/consumer-a/src/Migrations.php | 63 +++++++++++++++
tests/Fixtures/consumer-a/src/Options.php | 49 ++++++++++++
tests/Fixtures/consumer-a/src/Plugin.php | 20 ++++-
tests/Fixtures/consumer-a/uninstall.php | 6 +-
tests/Fixtures/personas/core/functions.php | 5 +-
.../personas/core/includes/footprint.php | 32 --------
.../Fixtures/personas/core/includes/index.php | 1 -
.../personas/core/includes/migrations.php | 41 ----------
.../personas/core/includes/options.php | 23 ------
.../Fixtures/personas/core/phpstan.dist.neon | 1 -
tests/Fixtures/personas/core/src/Greeting.php | 4 +-
.../Fixtures/personas/core/src/Migrations.php | 63 +++++++++++++++
tests/Fixtures/personas/core/src/Options.php | 67 ++++++++++++++++
tests/Fixtures/personas/core/src/Plugin.php | 19 ++++-
tests/Fixtures/personas/core/uninstall.php | 8 +-
26 files changed, 408 insertions(+), 316 deletions(-)
create mode 100644 tests/Fixtures/consumer-a/Safeguards.php
delete mode 100644 tests/Fixtures/consumer-a/includes/footprint.php
delete mode 100644 tests/Fixtures/consumer-a/includes/migrations.php
delete mode 100644 tests/Fixtures/consumer-a/includes/safeguards.php
create mode 100644 tests/Fixtures/consumer-a/src/Migrations.php
create mode 100644 tests/Fixtures/consumer-a/src/Options.php
delete mode 100644 tests/Fixtures/personas/core/includes/footprint.php
delete mode 100644 tests/Fixtures/personas/core/includes/index.php
delete mode 100644 tests/Fixtures/personas/core/includes/migrations.php
delete mode 100644 tests/Fixtures/personas/core/includes/options.php
create mode 100644 tests/Fixtures/personas/core/src/Migrations.php
create mode 100644 tests/Fixtures/personas/core/src/Options.php
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index e2f4882..a70c5f1 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -92,4 +92,4 @@ jobs:
with:
artifact: consumer-a
php-versions: '["7.4"]'
- paths: '["vendor-prefixed/ahegyes/wp-framework-bootstrap", "acme-consumer-a.php", "uninstall.php", "includes"]'
+ paths: '["vendor-prefixed/ahegyes/wp-framework-bootstrap", "acme-consumer-a.php", "uninstall.php", "Safeguards.php"]'
diff --git a/AGENTS.md b/AGENTS.md
index 9794f30..1f59e47 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,7 +80,7 @@ actionlint && zizmor .github/
2. **Nodes and the container.** Node classes never extend, implement or `use` optional or late-loaded types. Never constructor-inject a gated component; inject ungated services. Every injected service that implements `ComponentInterface` is also a node, because only nodes run `register_hooks()`. Definitions are static closures, taking `ContainerInterface $c` only when they read the container, or autowiring, never PHP-DI helpers. Hold the built container as `ContainerInterface`. Container reads belong in the kernel and composition wiring: a class-entry read checks the entry's type before use, and a factory collecting contributors re-applies their `should_load()` before resolving them. Framework classes take their logger injected; the composition root chooses the logger. A failure is logged with its Throwable under `exception`, a `WP_Error`'s Throwable data included. The uninstall engine runs without a composition root, so it writes the jobs it could not unschedule to PHP's error log, one line per site.
3. **Constructors and `register_hooks()`.** Constructors take services only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation.
4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the project controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type.
-5. **Safeguards.** Everything a safeguard calls lives in `includes/safeguards.php` or files it requires. The entry loads that file before `load.php` and the floor gate, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, or guards them. Relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`.
+5. **Safeguards.** A plugin's safeguards are one final class in `Safeguards.php` at the plugin root, beside the entry, which no autoload root maps; everything a safeguard calls lives in it or files it requires. The entry requires it by path before `load.php` and the floor gate and calls its one public static entry point, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, and, in a plugin that requires WooCommerce, WooCommerce APIs present since 3.6, guarding anything newer. A safeguard that calls WooCommerce is hooked from the class's one `woocommerce_loaded` callback, since WordPress loads a plugin while WooCommerce is inactive. Each safeguard is a static closure, which no code can remove by name, so relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`; helpers are protected, and only what other classes call is public.
6. **Shared logic.** `src/` holds a plugin's composition root and its components. Code that registers no hooks and is not part of the tree, such as option keys and readers, the footprint, migration steps and asset registrars, is prefixed functions and constants in `includes/`, which `functions.php` loads. Per-request state lives in a static holder or `WC()->session`. No service locator.
7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant.
8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components store data only under the consumer's `{prefix}_`, so the plugin's footprint covers it; a `TransientCache` prefix and any generation option it names start with it, so the footprint covers the generation option, and its entries, transients that all carry a timeout, expire on their own. An `ObjectCache` group starts with it too, which keeps it apart from core's and other plugins' groups. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`.
diff --git a/packages/core/README.md b/packages/core/README.md
index 4566fca..7e63315 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -112,7 +112,7 @@ function acme_pc_boot(): void {
}
```
-One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's `includes/migrations.php` returns as prefixed functions, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks:
+One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's final `Migrations` class returns as first-class callables to its protected statics, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks:
```php
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
@@ -153,7 +153,7 @@ final class Plugin {
// ...
protected function compose(): bool {
- $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', Migrations::get_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
@@ -194,19 +194,17 @@ final class Greeting implements ComponentInterface {
`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $plugin_file, $slug )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant.
-The plugin's `uninstall.php` hands its footprint to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data:
+The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data:
```php
-function acme_pc_get_footprint(): array {
- return array(
- 'option_prefixes' => array( Plugin::PREFIX . '_' ),
- 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
- 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
- 'network_options' => array( Plugin::PREFIX . '_network_notices' ),
+ public const array FOOTPRINT = array(
+ 'option_prefixes' => array( self::PREFIX . '_' ),
+ 'user_meta_site_prefixes' => array( self::PREFIX . '_' ),
+ 'user_meta_global_prefixes' => array( self::PREFIX . '_' ),
+ 'network_options' => array( self::PREFIX . '_network_notices' ),
'cron_hooks' => array( 'acme_persona_core/migration/run' ),
- 'as_groups' => array( Plugin::PREFIX ),
+ 'as_groups' => array( self::PREFIX ),
);
-}
```
```php
diff --git a/phpstan.bootstrap.neon b/phpstan.bootstrap.neon
index c4c4922..d429636 100644
--- a/phpstan.bootstrap.neon
+++ b/phpstan.bootstrap.neon
@@ -8,8 +8,8 @@ includes:
parameters:
paths:
- packages/bootstrap
- - tests/Fixtures/consumer-a/includes/safeguards.php
- # The bootstrap package and consumer-a's safeguard run before any floor is checked, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on.
+ - tests/Fixtures/consumer-a/Safeguards.php
+ # The bootstrap package and consumer-a's safeguards run before any floor is checked, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on.
phpVersion: 70400
WPCompat:
requiresAtLeast: '5.2'
diff --git a/tests/Fixtures/consumer-a/Safeguards.php b/tests/Fixtures/consumer-a/Safeguards.php
new file mode 100644
index 0000000..89b267e
--- /dev/null
+++ b/tests/Fixtures/consumer-a/Safeguards.php
@@ -0,0 +1,80 @@
+getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed.
- add_action( 'all_admin_notices', 'acme_ca_render_build_notice' );
-}
-
-/**
- * Prints the notice that the plugin's build is incomplete to users who can activate plugins.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @return void
- */
-function acme_ca_render_build_notice(): void {
- if ( ! current_user_can( 'activate_plugins' ) ) {
- return;
- }
-
- printf( '', esc_html__( 'Acme Consumer A is missing some of its files, so its features are off. Reinstall the plugin to restore them.', 'acme-consumer-a' ) );
+ add_action(
+ 'all_admin_notices',
+ static function (): void {
+ if ( current_user_can( 'activate_plugins' ) ) {
+ printf( '', esc_html__( 'Acme Consumer A is missing some of its files, so its features are off. Reinstall the plugin to restore them.', 'acme-consumer-a' ) );
+ }
+ }
+ );
}
diff --git a/tests/Fixtures/consumer-a/functions.php b/tests/Fixtures/consumer-a/functions.php
index aa503d5..55939bd 100644
--- a/tests/Fixtures/consumer-a/functions.php
+++ b/tests/Fixtures/consumer-a/functions.php
@@ -46,7 +46,7 @@ function acme_ca_plugin(): Plugin {
}
/**
- * Boots the plugin, reporting a composition root that cannot be built as an incomplete build.
+ * Boots the plugin.
*
* @since 1.0.0
* @version 1.0.0
@@ -54,11 +54,7 @@ function acme_ca_plugin(): Plugin {
* @return void
*/
function acme_ca_boot(): void {
- try {
- acme_ca_plugin()->boot();
- } catch ( Throwable $error ) {
- acme_ca_handle_build_error( $error );
- }
+ acme_ca_plugin()->boot();
}
/**
diff --git a/tests/Fixtures/consumer-a/includes/footprint.php b/tests/Fixtures/consumer-a/includes/footprint.php
deleted file mode 100644
index 62e01b7..0000000
--- a/tests/Fixtures/consumer-a/includes/footprint.php
+++ /dev/null
@@ -1,34 +0,0 @@
-, meta: array{post: list}, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list}
- */
-function acme_ca_get_footprint(): array {
- return array(
- 'option_prefixes' => array( Plugin::PREFIX . '_', '_transient_' . Plugin::PREFIX . '_', '_transient_timeout_' . Plugin::PREFIX . '_' ),
- 'meta' => array( 'post' => array( ProductData::NOTE_KEY ) ),
- 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
- 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
- 'network_options' => array( Plugin::PREFIX . '_network_notices' ),
- 'cron_hooks' => array( 'acme_consumer_a/migration/run' ),
- 'as_groups' => array( Plugin::PREFIX ),
- );
-}
diff --git a/tests/Fixtures/consumer-a/includes/migrations.php b/tests/Fixtures/consumer-a/includes/migrations.php
deleted file mode 100644
index 1a12495..0000000
--- a/tests/Fixtures/consumer-a/includes/migrations.php
+++ /dev/null
@@ -1,43 +0,0 @@
-
- */
-function acme_ca_get_migration_steps(): array {
- return array(
- 'acme_ca_add_settings',
- );
-}
-
-/**
- * Stores the plugin's settings with their defaults, which keep the plugin's data on uninstall.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @throws RuntimeException Thrown when the settings could not be stored.
- *
- * @return void
- */
-function acme_ca_add_settings(): void {
- if ( false === get_option( Plugin::PREFIX . '_settings' ) && ! add_option( Plugin::PREFIX . '_settings', array( 'delete_data' => 'no' ) ) ) {
- throw new RuntimeException( 'The settings could not be stored.' );
- }
-}
diff --git a/tests/Fixtures/consumer-a/includes/safeguards.php b/tests/Fixtures/consumer-a/includes/safeguards.php
deleted file mode 100644
index 2b4126c..0000000
--- a/tests/Fixtures/consumer-a/includes/safeguards.php
+++ /dev/null
@@ -1,54 +0,0 @@
-
+ */
+ public static function get_steps(): array {
+ return array(
+ self::add_settings( ... ),
+ );
+ }
+
+ // endregion
+
+ // region HELPERS
+
+ /**
+ * Stores the plugin's settings with their defaults.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @throws \RuntimeException Thrown when the settings could not be stored.
+ *
+ * @return void
+ */
+ protected static function add_settings(): void {
+ if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::DEFAULTS ) ) {
+ throw new \RuntimeException( 'The settings could not be stored.' );
+ }
+ }
+
+ // endregion
+}
diff --git a/tests/Fixtures/consumer-a/src/Options.php b/tests/Fixtures/consumer-a/src/Options.php
new file mode 100644
index 0000000..6522312
--- /dev/null
+++ b/tests/Fixtures/consumer-a/src/Options.php
@@ -0,0 +1,49 @@
+ 'no' );
+
+ // endregion
+
+ // region MAGIC METHODS
+
+ /**
+ * Constructor.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ */
+ protected function __construct() {}
+
+ // endregion
+}
diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php
index 0d8d792..38d7b2f 100644
--- a/tests/Fixtures/consumer-a/src/Plugin.php
+++ b/tests/Fixtures/consumer-a/src/Plugin.php
@@ -61,6 +61,24 @@ final class Plugin {
FaultInjector::class,
);
+ /**
+ * The plugin's stored data by kind, which covers every option, transient, meta key and scheduled job under the plugin's prefix.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var array{option_prefixes: list, meta: array{post: list}, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list}
+ */
+ public const array FOOTPRINT = array(
+ 'option_prefixes' => array( self::PREFIX . '_', '_transient_' . self::PREFIX . '_', '_transient_timeout_' . self::PREFIX . '_' ),
+ 'meta' => array( 'post' => array( ProductData::NOTE_KEY ) ),
+ 'user_meta_site_prefixes' => array( self::PREFIX . '_' ),
+ 'user_meta_global_prefixes' => array( self::PREFIX . '_' ),
+ 'network_options' => array( self::PREFIX . '_network_notices' ),
+ 'cron_hooks' => array( 'acme_consumer_a/migration/run' ),
+ 'as_groups' => array( self::PREFIX ),
+ );
+
/**
* The boot boundary, whose status tells whether the plugin booted.
*
@@ -161,7 +179,7 @@ protected function compose(): bool {
return false;
}
- $this->migrations = new MigrationRunner( ACME_CA_FILE, self::SLUG, self::PREFIX . '_revision', acme_ca_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( ACME_CA_FILE, self::SLUG, self::PREFIX . '_revision', Migrations::get_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php
index 58a0d6f..2adf079 100644
--- a/tests/Fixtures/consumer-a/uninstall.php
+++ b/tests/Fixtures/consumer-a/uninstall.php
@@ -8,6 +8,7 @@
* @package Acme\ConsumerA
*/
+use Acme\ConsumerA\Options;
use Acme\ConsumerA\Plugin;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller;
@@ -28,9 +29,8 @@
}
require_once __DIR__ . '/vendor/autoload.php';
- require_once __DIR__ . '/includes/footprint.php';
- $acme_ca_footprint = acme_ca_get_footprint();
- $acme_ca_consent = Plugin::PREFIX . '_settings';
+ $acme_ca_footprint = Plugin::FOOTPRINT;
+ $acme_ca_consent = Options::SETTINGS;
$acme_ca_meta_types = MetaTypes::DEFINITIONS;
if ( ! class_exists( Uninstaller::class ) ) {
throw new RuntimeException( 'its uninstall engine is missing.' );
diff --git a/tests/Fixtures/personas/core/functions.php b/tests/Fixtures/personas/core/functions.php
index a9bb42f..745ad2b 100644
--- a/tests/Fixtures/personas/core/functions.php
+++ b/tests/Fixtures/personas/core/functions.php
@@ -1,6 +1,6 @@
, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list}
- */
-function acme_pc_get_footprint(): array {
- return array(
- 'option_prefixes' => array( Plugin::PREFIX . '_' ),
- 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
- 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
- 'network_options' => array( Plugin::PREFIX . '_network_notices' ),
- 'cron_hooks' => array( 'acme_persona_core/migration/run' ),
- 'as_groups' => array( Plugin::PREFIX ),
- );
-}
diff --git a/tests/Fixtures/personas/core/includes/index.php b/tests/Fixtures/personas/core/includes/index.php
deleted file mode 100644
index f767346..0000000
--- a/tests/Fixtures/personas/core/includes/index.php
+++ /dev/null
@@ -1 +0,0 @@
-
- */
-function acme_pc_get_migration_steps(): array {
- return array(
- 'acme_pc_add_settings',
- );
-}
-
-/**
- * Stores the plugin's settings with their defaults.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @throws RuntimeException Thrown when the settings could not be stored.
- *
- * @return void
- */
-function acme_pc_add_settings(): void {
- if ( false === get_option( ACME_PC_OPTION_SETTINGS ) && ! add_option( ACME_PC_OPTION_SETTINGS, array( 'greeting' => 'Hello' ) ) ) {
- throw new RuntimeException( 'The settings could not be stored.' );
- }
-}
diff --git a/tests/Fixtures/personas/core/includes/options.php b/tests/Fixtures/personas/core/includes/options.php
deleted file mode 100644
index 3bb77a3..0000000
--- a/tests/Fixtures/personas/core/includes/options.php
+++ /dev/null
@@ -1,23 +0,0 @@
-
+ */
+ public static function get_steps(): array {
+ return array(
+ self::add_settings( ... ),
+ );
+ }
+
+ // endregion
+
+ // region HELPERS
+
+ /**
+ * Stores the plugin's settings with their defaults.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @throws \RuntimeException Thrown when the settings could not be stored.
+ *
+ * @return void
+ */
+ protected static function add_settings(): void {
+ if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::DEFAULTS ) ) {
+ throw new \RuntimeException( 'The settings could not be stored.' );
+ }
+ }
+
+ // endregion
+}
diff --git a/tests/Fixtures/personas/core/src/Options.php b/tests/Fixtures/personas/core/src/Options.php
new file mode 100644
index 0000000..48da6c8
--- /dev/null
+++ b/tests/Fixtures/personas/core/src/Options.php
@@ -0,0 +1,67 @@
+ 'Hello' );
+
+ // endregion
+
+ // region MAGIC METHODS
+
+ /**
+ * Constructor.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ */
+ protected function __construct() {}
+
+ // endregion
+
+ // region METHODS
+
+ /**
+ * Returns the stored greeting, or the default one where none is stored.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @return string
+ */
+ public static function get_greeting(): string {
+ $settings = get_option( self::SETTINGS, self::DEFAULTS );
+
+ return \is_array( $settings ) && \is_string( $settings['greeting'] ?? null ) ? $settings['greeting'] : self::DEFAULTS['greeting'];
+ }
+
+ // endregion
+}
diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php
index 8ebdf3d..6068adb 100644
--- a/tests/Fixtures/personas/core/src/Plugin.php
+++ b/tests/Fixtures/personas/core/src/Plugin.php
@@ -57,6 +57,23 @@ final class Plugin {
Greeting::class,
);
+ /**
+ * The plugin's stored data by kind, which covers every option, user meta key and scheduled job under the plugin's prefix.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var array{option_prefixes: list, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list}
+ */
+ public const array FOOTPRINT = array(
+ 'option_prefixes' => array( self::PREFIX . '_' ),
+ 'user_meta_site_prefixes' => array( self::PREFIX . '_' ),
+ 'user_meta_global_prefixes' => array( self::PREFIX . '_' ),
+ 'network_options' => array( self::PREFIX . '_network_notices' ),
+ 'cron_hooks' => array( 'acme_persona_core/migration/run' ),
+ 'as_groups' => array( self::PREFIX ),
+ );
+
/**
* The boot boundary, whose status tells whether the plugin booted.
*
@@ -152,7 +169,7 @@ public function boot(): void {
* @return bool
*/
protected function compose(): bool {
- $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', Migrations::get_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
diff --git a/tests/Fixtures/personas/core/uninstall.php b/tests/Fixtures/personas/core/uninstall.php
index 6dd4e7e..cc4e083 100644
--- a/tests/Fixtures/personas/core/uninstall.php
+++ b/tests/Fixtures/personas/core/uninstall.php
@@ -8,6 +8,8 @@
* @package Acme\PersonaCore
*/
+use Acme\PersonaCore\Options;
+use Acme\PersonaCore\Plugin;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller;
@@ -26,10 +28,8 @@
}
require_once __DIR__ . '/vendor/autoload.php';
- require_once __DIR__ . '/includes/footprint.php';
- require_once __DIR__ . '/includes/options.php';
- $acme_pc_footprint = acme_pc_get_footprint();
- $acme_pc_consent = ACME_PC_OPTION_SETTINGS;
+ $acme_pc_footprint = Plugin::FOOTPRINT;
+ $acme_pc_consent = Options::SETTINGS;
if ( ! class_exists( Uninstaller::class ) ) {
throw new RuntimeException( 'its uninstall engine is missing.' );
}
From f9895c62fabb26815e695a4eaacb6f485fabae65 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:34:05 +0100
Subject: [PATCH 08/23] Keep only contract in the fixtures' global namespace
The fixtures' functions.php declared the accessor and every hook
callback as named globals without @api, so the global namespace held
wiring that any code could call, unhook or come to rely on, and the
woocommerce persona kept its option name, meta key and footprint as
globals in includes/. Convention 6 still placed option keys, the
footprint, migration steps and asset registrars there as prefixed
globals.
Each accessor stays global and is tagged @api, its contract being that
it returns the composition root, whose members stay internal unless
tagged. The boot, the compatibility declaration and the personas'
settings and product data registrations are static closures in
functions.php. The woocommerce persona's option name moves to an
Options class and its meta key and footprint to Packing, which holds
its slug and prefix, and its includes/ goes. The README excerpts copy
the new code, and convention 6 states where each kind of symbol lives.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
packages/core/README.md | 13 +--
packages/settings/README.md | 22 ++---
packages/woocommerce/README.md | 71 +++++++--------
tests/Fixtures/consumer-a/functions.php | 43 ++++-----
tests/Fixtures/consumer-b/functions.php | 24 +++--
tests/Fixtures/personas/core/functions.php | 24 +++--
.../Fixtures/personas/settings/functions.php | 26 ++----
.../personas/woocommerce/functions.php | 91 +++++++------------
.../woocommerce/includes/footprint.php | 28 ------
.../personas/woocommerce/includes/index.php | 1 -
.../personas/woocommerce/includes/options.php | 33 -------
.../personas/woocommerce/phpstan.dist.neon | 1 -
.../personas/woocommerce/src/Options.php | 39 ++++++++
.../personas/woocommerce/src/Packing.php | 27 +++++-
.../personas/woocommerce/uninstall.php | 8 +-
16 files changed, 202 insertions(+), 251 deletions(-)
delete mode 100644 tests/Fixtures/personas/woocommerce/includes/footprint.php
delete mode 100644 tests/Fixtures/personas/woocommerce/includes/index.php
delete mode 100644 tests/Fixtures/personas/woocommerce/includes/options.php
create mode 100644 tests/Fixtures/personas/woocommerce/src/Options.php
diff --git a/AGENTS.md b/AGENTS.md
index 1f59e47..ae5311a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,7 +81,7 @@ actionlint && zizmor .github/
3. **Constructors and `register_hooks()`.** Constructors take services only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation.
4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the project controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type.
5. **Safeguards.** A plugin's safeguards are one final class in `Safeguards.php` at the plugin root, beside the entry, which no autoload root maps; everything a safeguard calls lives in it or files it requires. The entry requires it by path before `load.php` and the floor gate and calls its one public static entry point, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, and, in a plugin that requires WooCommerce, WooCommerce APIs present since 3.6, guarding anything newer. A safeguard that calls WooCommerce is hooked from the class's one `woocommerce_loaded` callback, since WordPress loads a plugin while WooCommerce is inactive. Each safeguard is a static closure, which no code can remove by name, so relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`; helpers are protected, and only what other classes call is public.
-6. **Shared logic.** `src/` holds a plugin's composition root and its components. Code that registers no hooks and is not part of the tree, such as option keys and readers, the footprint, migration steps and asset registrars, is prefixed functions and constants in `includes/`, which `functions.php` loads. Per-request state lives in a static holder or `WC()->session`. No service locator.
+6. **Shared logic.** The global namespace holds only a plugin's contract. `functions.php` declares its `@api` functions, the accessor among them, which returns the composition root while the root's members stay internal unless tagged `@api`, and registers its hooks as static closures; `includes/`, which `functions.php` loads, holds more `@api` functions and constants, grouped by topic. Every other symbol is a member of a final class in `src/` named for its concept: the composition root, which also holds the footprint, a component, or a class that registers no hooks, such as `Options`, whose constants name the stored settings and their defaults and whose public statics read them, and `Migrations`, whose one public method lists its protected static steps as first-class callables, while a batched step is a final class of its own and a meta key stays on the class that stores it. Built scripts and styles register through core's `Assets`, which the container defines. No namespace or folder is named for being internal. Per-request state lives in a static holder or `WC()->session`. No service locator.
7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant.
8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components store data only under the consumer's `{prefix}_`, so the plugin's footprint covers it; a `TransientCache` prefix and any generation option it names start with it, so the footprint covers the generation option, and its entries, transients that all carry a timeout, expire on their own. An `ObjectCache` group starts with it too, which keeps it apart from core's and other plugins' groups. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`.
9. **Object forms** render through `Forms::render_control()`, print their nonce bound to the object and verify it before they evaluate their definition; every framework saver goes through `Forms::sanitize_fields()` and leaves absent (unrendered or disabled) fields untouched, but for limitation 4's WooCommerce checkboxes and multiselects.
diff --git a/packages/core/README.md b/packages/core/README.md
index 7e63315..432052c 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -89,10 +89,13 @@ require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/functions.php';
```
-It boots on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded:
+Its `functions.php` defines the accessor, the plugin's one `@api` function, which returns the composition root while the root's members stay internal, and boots the plugin from a static closure on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded:
```php
-add_action( 'plugins_loaded', 'acme_pc_boot', 15 );
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ acme_pc_plugin()->boot();
// ...
@@ -104,12 +107,6 @@ function acme_pc_plugin(): Plugin {
return $plugin;
}
-
-// ...
-
-function acme_pc_boot(): void {
- acme_pc_plugin()->boot();
-}
```
One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's final `Migrations` class returns as first-class callables to its protected statics, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks:
diff --git a/packages/settings/README.md b/packages/settings/README.md
index f20518b..27acdb9 100644
--- a/packages/settings/README.md
+++ b/packages/settings/README.md
@@ -99,17 +99,17 @@ use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\O
defined( 'ABSPATH' ) || exit;
-add_action( 'plugins_loaded', 'acme_ps_register_hooks', 15 );
-
-// ...
-
-function acme_ps_register_hooks(): void {
- $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
- $notices->register_hooks();
-
- new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
- new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
-}
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
+ $notices->register_hooks();
+
+ new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
+ new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
+ },
+ 15
+);
```
A definition object declares the page and its fields as data. A field's `validate` callback returns the value to store, or a `WP_Error` whose message the page shows while the stored value stays:
diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md
index fd9177f..98b96d4 100644
--- a/packages/woocommerce/README.md
+++ b/packages/woocommerce/README.md
@@ -103,42 +103,39 @@ use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\WooCom
defined( 'ABSPATH' ) || exit;
-require_once __DIR__ . '/includes/options.php';
-
-add_action( 'plugins_loaded', 'acme_pw_register_hooks', 15 );
-
-// ...
-
-function acme_pw_register_hooks(): void {
- if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) {
- return;
- }
-
- new ProductDataTab( Packing::SLUG, Packing::PREFIX, new Packing() )->register_hooks();
- add_filter( 'woocommerce_get_settings_pages', 'acme_pw_add_settings_tab' );
- add_action( 'woocommerce_update_options_' . Packing::PREFIX, 'acme_pw_log_settings_save' );
-}
-
-// ...
-
-function acme_pw_add_settings_tab( array $pages ): array {
- $pages[] = new SettingsTab(
- Packing::PREFIX,
- array(
- 'label' => __( 'Packing', 'acme-persona-woocommerce' ),
- 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ),
- ),
- new Packing()
- );
-
- return $pages;
-}
-
-// ...
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) {
+ return;
+ }
+
+ new ProductDataTab( Packing::SLUG, Packing::PREFIX, new Packing() )->register_hooks();
+ add_filter(
+ 'woocommerce_get_settings_pages',
+ static function ( array $pages ): array {
+ // WooCommerce's settings page class, which the tab extends, exists only once this filter runs.
+ $pages[] = new SettingsTab(
+ Packing::PREFIX,
+ array(
+ 'label' => __( 'Packing', 'acme-persona-woocommerce' ),
+ 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ),
+ ),
+ new Packing()
+ );
-function acme_pw_log_settings_save(): void {
- new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' );
-}
+ return $pages;
+ }
+ );
+ add_action(
+ 'woocommerce_update_options_' . Packing::PREFIX,
+ static function (): void {
+ new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' );
+ }
+ );
+ },
+ 15
+);
```
A definition object declares both tabs' fields as data, which WooCommerce renders, validates and saves:
@@ -157,7 +154,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD
),
array(
'type' => 'checkbox',
- 'id' => ACME_PW_OPTION_DELETE_DATA,
+ 'id' => Options::DELETE_DATA,
'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ),
'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ),
),
@@ -175,7 +172,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD
'fields' => array(
array(
'type' => 'text',
- 'id' => ACME_PW_META_BOX,
+ 'id' => self::META_BOX,
'label' => __( 'Box', 'acme-persona-woocommerce' ),
),
),
diff --git a/tests/Fixtures/consumer-a/functions.php b/tests/Fixtures/consumer-a/functions.php
index 55939bd..e072332 100644
--- a/tests/Fixtures/consumer-a/functions.php
+++ b/tests/Fixtures/consumer-a/functions.php
@@ -13,8 +13,19 @@
defined( 'ABSPATH' ) || exit;
-add_action( 'plugins_loaded', 'acme_ca_boot', 15 );
-add_action( 'before_woocommerce_init', 'acme_ca_declare_compatibility' );
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ acme_ca_plugin()->boot();
+ },
+ 15
+);
+add_action(
+ 'before_woocommerce_init',
+ static function (): void {
+ FeaturesUtil::declare_compatibility( 'custom_order_tables', ACME_CA_FILE, true );
+ }
+);
$acme_ca_includes = glob( __DIR__ . '/includes/*.php' );
if ( false === $acme_ca_includes ) {
@@ -31,6 +42,10 @@
/**
* Returns the plugin's composition root, built on first use.
*
+ * Only the function is contract: the root's members stay internal unless tagged @api.
+ *
+ * @api
+ *
* @since 1.0.0
* @version 1.0.0
*
@@ -44,27 +59,3 @@ function acme_ca_plugin(): Plugin {
return $plugin;
}
-
-/**
- * Boots the plugin.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @return void
- */
-function acme_ca_boot(): void {
- acme_ca_plugin()->boot();
-}
-
-/**
- * Declares that the plugin works with WooCommerce's order tables.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @return void
- */
-function acme_ca_declare_compatibility(): void {
- FeaturesUtil::declare_compatibility( 'custom_order_tables', ACME_CA_FILE, true );
-}
diff --git a/tests/Fixtures/consumer-b/functions.php b/tests/Fixtures/consumer-b/functions.php
index 429a12c..bae76a4 100644
--- a/tests/Fixtures/consumer-b/functions.php
+++ b/tests/Fixtures/consumer-b/functions.php
@@ -12,11 +12,21 @@
defined( 'ABSPATH' ) || exit;
-add_action( 'plugins_loaded', 'acme_cb_boot', 15 );
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ acme_cb_plugin()->boot();
+ },
+ 15
+);
/**
* Returns the plugin's composition root, built on first use.
*
+ * Only the function is contract: the root's members stay internal unless tagged @api.
+ *
+ * @api
+ *
* @since 1.0.0
* @version 1.0.0
*
@@ -30,15 +40,3 @@ function acme_cb_plugin(): Plugin {
return $plugin;
}
-
-/**
- * Boots the plugin.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @return void
- */
-function acme_cb_boot(): void {
- acme_cb_plugin()->boot();
-}
diff --git a/tests/Fixtures/personas/core/functions.php b/tests/Fixtures/personas/core/functions.php
index 745ad2b..d53a194 100644
--- a/tests/Fixtures/personas/core/functions.php
+++ b/tests/Fixtures/personas/core/functions.php
@@ -12,11 +12,21 @@
defined( 'ABSPATH' ) || exit;
-add_action( 'plugins_loaded', 'acme_pc_boot', 15 );
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ acme_pc_plugin()->boot();
+ },
+ 15
+);
/**
* Returns the plugin's composition root, built on first use.
*
+ * Only the function is contract: the root's members stay internal unless tagged @api.
+ *
+ * @api
+ *
* @since 1.0.0
* @version 1.0.0
*
@@ -30,15 +40,3 @@ function acme_pc_plugin(): Plugin {
return $plugin;
}
-
-/**
- * Boots the plugin.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @return void
- */
-function acme_pc_boot(): void {
- acme_pc_plugin()->boot();
-}
diff --git a/tests/Fixtures/personas/settings/functions.php b/tests/Fixtures/personas/settings/functions.php
index 394d170..c74d0f8 100644
--- a/tests/Fixtures/personas/settings/functions.php
+++ b/tests/Fixtures/personas/settings/functions.php
@@ -18,20 +18,14 @@
defined( 'ABSPATH' ) || exit;
-add_action( 'plugins_loaded', 'acme_ps_register_hooks', 15 );
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
+ $notices->register_hooks();
-/**
- * Registers the hooks of the plugin's settings page, of its post form and of the notices that report the form's rejections.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @return void
- */
-function acme_ps_register_hooks(): void {
- $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
- $notices->register_hooks();
-
- new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
- new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
-}
+ new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
+ new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
+ },
+ 15
+);
diff --git a/tests/Fixtures/personas/woocommerce/functions.php b/tests/Fixtures/personas/woocommerce/functions.php
index 3c46f95..1db6b34 100644
--- a/tests/Fixtures/personas/woocommerce/functions.php
+++ b/tests/Fixtures/personas/woocommerce/functions.php
@@ -1,6 +1,6 @@
register_hooks();
- add_filter( 'woocommerce_get_settings_pages', 'acme_pw_add_settings_tab' );
- add_action( 'woocommerce_update_options_' . Packing::PREFIX, 'acme_pw_log_settings_save' );
-}
-
-/**
- * Adds the plugin's settings tab, which WooCommerce's settings page class must exist to construct.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @param array $pages The settings tabs.
- *
- * @return array
- */
-function acme_pw_add_settings_tab( array $pages ): array {
- $pages[] = new SettingsTab(
- Packing::PREFIX,
- array(
- 'label' => __( 'Packing', 'acme-persona-woocommerce' ),
- 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ),
- ),
- new Packing()
- );
-
- return $pages;
-}
-
-/**
- * Writes the save of the plugin's settings tab to WooCommerce's log.
- *
- * @since 1.0.0
- * @version 1.0.0
- *
- * @return void
- */
-function acme_pw_log_settings_save(): void {
- new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' );
-}
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) {
+ return;
+ }
+
+ new ProductDataTab( Packing::SLUG, Packing::PREFIX, new Packing() )->register_hooks();
+ add_filter(
+ 'woocommerce_get_settings_pages',
+ static function ( array $pages ): array {
+ // WooCommerce's settings page class, which the tab extends, exists only once this filter runs.
+ $pages[] = new SettingsTab(
+ Packing::PREFIX,
+ array(
+ 'label' => __( 'Packing', 'acme-persona-woocommerce' ),
+ 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ),
+ ),
+ new Packing()
+ );
+
+ return $pages;
+ }
+ );
+ add_action(
+ 'woocommerce_update_options_' . Packing::PREFIX,
+ static function (): void {
+ new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' );
+ }
+ );
+ },
+ 15
+);
diff --git a/tests/Fixtures/personas/woocommerce/includes/footprint.php b/tests/Fixtures/personas/woocommerce/includes/footprint.php
deleted file mode 100644
index c0f985f..0000000
--- a/tests/Fixtures/personas/woocommerce/includes/footprint.php
+++ /dev/null
@@ -1,28 +0,0 @@
-, meta: array{post: list}}
- */
-function acme_pw_get_footprint(): array {
- return array(
- 'option_prefixes' => array( Packing::PREFIX . '_' ),
- 'meta' => array( 'post' => array( ACME_PW_META_BOX ) ),
- );
-}
diff --git a/tests/Fixtures/personas/woocommerce/includes/index.php b/tests/Fixtures/personas/woocommerce/includes/index.php
deleted file mode 100644
index f767346..0000000
--- a/tests/Fixtures/personas/woocommerce/includes/index.php
+++ /dev/null
@@ -1 +0,0 @@
-, meta: array{post: list}}
+ */
+ public const array FOOTPRINT = array(
+ 'option_prefixes' => array( self::PREFIX . '_' ),
+ 'meta' => array( 'post' => array( self::META_BOX ) ),
+ );
+
// endregion
// region INHERITED METHODS
@@ -56,7 +79,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array
),
array(
'type' => 'checkbox',
- 'id' => ACME_PW_OPTION_DELETE_DATA,
+ 'id' => Options::DELETE_DATA,
'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ),
'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ),
),
@@ -78,7 +101,7 @@ public function get_product_data_tab( string $tab_id, \WC_Product $product ): ar
'fields' => array(
array(
'type' => 'text',
- 'id' => ACME_PW_META_BOX,
+ 'id' => self::META_BOX,
'label' => __( 'Box', 'acme-persona-woocommerce' ),
),
),
diff --git a/tests/Fixtures/personas/woocommerce/uninstall.php b/tests/Fixtures/personas/woocommerce/uninstall.php
index f6f0abf..60f146d 100644
--- a/tests/Fixtures/personas/woocommerce/uninstall.php
+++ b/tests/Fixtures/personas/woocommerce/uninstall.php
@@ -8,6 +8,8 @@
* @package Acme\PersonaWooCommerce
*/
+use Acme\PersonaWooCommerce\Options;
+use Acme\PersonaWooCommerce\Packing;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes;
@@ -27,10 +29,8 @@
}
require_once __DIR__ . '/vendor/autoload.php';
- require_once __DIR__ . '/includes/footprint.php';
- require_once __DIR__ . '/includes/options.php';
- $acme_pw_footprint = acme_pw_get_footprint();
- $acme_pw_consent = ACME_PW_OPTION_DELETE_DATA;
+ $acme_pw_footprint = Packing::FOOTPRINT;
+ $acme_pw_consent = Options::DELETE_DATA;
$acme_pw_meta_types = MetaTypes::DEFINITIONS;
if ( ! class_exists( Uninstaller::class ) ) {
throw new RuntimeException( 'its uninstall engine is missing.' );
From c99e4610855b7088414a9f73701bba1b42fc7f1a Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:34:26 +0100
Subject: [PATCH 09/23] Name the scheduler's group after the plugin's prefix
The scheduler's constructor documented its group as the plugin's hook
prefix, but the framework's hooks are rooted in the plugin's slug, and
the group is the storage prefix that names its Action Scheduler group
and WP-Cron schedules.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/src/Scheduling/Scheduler.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php
index eab2dec..5da63f8 100644
--- a/packages/core/src/Scheduling/Scheduler.php
+++ b/packages/core/src/Scheduling/Scheduler.php
@@ -64,7 +64,7 @@ final class Scheduler implements SchedulerInterface, ComponentInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $group The plugin's hook prefix, which names its Action Scheduler group and prefixes its WP-Cron schedules.
+ * @param string $group The plugin's prefix, which names its Action Scheduler group and prefixes its WP-Cron schedules.
*
* @throws InvalidArgumentException Thrown when the group is invalid.
*/
From be63dd3e1c833bde1eb1e7f5ae30c2309c258c7f Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:52:11 +0100
Subject: [PATCH 10/23] Log the block-editor delivery row's user out
The post meta box row that fetches a rejection through the block
editor's delivery endpoint logged the administrator in and never logged
them out, so under some random orders a later row that needs nobody
logged in, such as the notices' rejection of a user notice without a
user, ran as the administrator and failed.
The row logs the user out in a finally, as its sibling that saves as
the administrator already does.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Settings/EditScreen/Hosts/PostMetaBoxTest.php | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
index d2990fe..4e00109 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
@@ -677,8 +677,12 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th
$saved = $this->block_editor_save( $spec, $post_id, array( $this->control_name( 'note' ) => 'too long' ) );
$redirected = $this->follow( $spec, $saved );
wp_set_current_user( $this->admin );
- $delivered = new AdminNotices( $this->slug, $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver();
- $list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec );
+ try {
+ $delivered = new AdminNotices( $this->slug, $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver();
+ } finally {
+ wp_set_current_user( 0 );
+ }
+ $list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec );
self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) );
self::assertNotNull( self::document( $redirected['body'] )->querySelector( 'form.metabox-base-form' ) );
From e9e8546bea7f4de70d5f545bcc79959ba6de3e2e Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:52:44 +0100
Subject: [PATCH 11/23] State one runner and one notices instance per plugin
The runner's scheduled hook and the notices' REST routes are named
after the plugin's slug, so two runners or two notices instances in one
plugin would share them, yet neither class said a plugin builds one.
Limitation 5 still described a template entry whose catch came after
the bootstrap package's require, while the reference entry now requires
it inside its one try.
The runner's and the notices' class docblocks state that a plugin
builds one of each, and limitation 5 describes the entry's catch, which
takes a partial bootstrap package's Error everywhere but on PHP 7.4.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
packages/core/src/AdminNotice/AdminNotices.php | 2 +-
packages/core/src/Migration/MigrationRunner.php | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index ae5311a..344a080 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -116,7 +116,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 cancel Action Scheduler's default store cannot write repeats until the request's time limit; 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. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the entry's catch shows the build notice, or rethrows in local and development environments, and the uninstall shim keeps the data, except on PHP 7.4, where a failed `require` is fatal at include. 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. A database write that keeps failing keeps Action Scheduler's bulk cancel looping until the request's time limit. 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. It reads each notice aloud through `wp.a11y.speak()`, which drops text between angle brackets, so a literal angle-bracketed placeholder in a message goes unspoken. 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/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php
index ef01921..a19dca8 100644
--- a/packages/core/src/AdminNotice/AdminNotices.php
+++ b/packages/core/src/AdminNotice/AdminNotices.php
@@ -13,7 +13,7 @@
/**
* Keeps a plugin's admin notices, shows them in the admin, the post editor included, and lets users dismiss them through a REST endpoint.
*
- * The slug names the plugin's REST namespace and, in snake case, roots its hooks, while the prefix names its storage keys. Each scope keeps its notices as one array by id: site notices in the {prefix}_notices option, network notices in the {prefix}_network_notices network option, user notices in the {prefix}_notices user option, and request notices in the instance, so components that add them must share it. A viewer's dismissals, and the one-shots shown to them that others still see, are kept in their {prefix}_hidden_notices user option, or for network notices in their {prefix}_network_hidden_notices user meta, keyed by network.
+ * The slug names the plugin's REST namespace and, in snake case, roots its hooks, while the prefix names its storage keys. Each scope keeps its notices as one array by id: site notices in the {prefix}_notices option, network notices in the {prefix}_network_notices network option, user notices in the {prefix}_notices user option, and request notices in the instance, so components that add them must share it, and a plugin builds one instance, since the slug names its routes. A viewer's dismissals, and the one-shots shown to them that others still see, are kept in their {prefix}_hidden_notices user option, or for network notices in their {prefix}_network_hidden_notices user meta, keyed by network.
*
* @since 2.0.0
* @version 2.0.0
diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php
index cf1067d..3aff7a6 100644
--- a/packages/core/src/Migration/MigrationRunner.php
+++ b/packages/core/src/Migration/MigrationRunner.php
@@ -22,7 +22,7 @@
/**
* Brings a plugin's stored data to the revision its code expects, running the pending migration steps in order under a database lock.
*
- * Plain steps run on wp_loaded of any request, while batched steps run only from the runner's own scheduled hook, so no visitor waits for bulk work.
+ * Plain steps run on wp_loaded of any request, while batched steps run only from the runner's own scheduled hook, so no visitor waits for bulk work. A plugin runs one runner, since the plugin's slug names that hook.
*
* @since 2.0.0
* @version 2.0.0
From 70d604700e5407c400c748d0e1f213f59ee7c430 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:59:30 +0100
Subject: [PATCH 12/23] Log users out after every row that can log one in
The block-editor delivery row's finally fixed one leak, but nothing
stopped the next row that logs a user in from leaking again, and the
rows that need nobody logged in still depended on every earlier row
cleaning up after itself in random order.
The user trait, which every row that logs a user in uses, logs the
current user out after each row, so the delivery row's own finally
goes.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Settings/EditScreen/Hosts/PostMetaBoxTest.php | 8 ++------
tests/Integration/Support/CreatesUsersTrait.php | 8 ++++++++
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
index 4e00109..d2990fe 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
@@ -677,12 +677,8 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th
$saved = $this->block_editor_save( $spec, $post_id, array( $this->control_name( 'note' ) => 'too long' ) );
$redirected = $this->follow( $spec, $saved );
wp_set_current_user( $this->admin );
- try {
- $delivered = new AdminNotices( $this->slug, $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver();
- } finally {
- wp_set_current_user( 0 );
- }
- $list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec );
+ $delivered = new AdminNotices( $this->slug, $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver();
+ $list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec );
self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) );
self::assertNotNull( self::document( $redirected['body'] )->querySelector( 'form.metabox-base-form' ) );
diff --git a/tests/Integration/Support/CreatesUsersTrait.php b/tests/Integration/Support/CreatesUsersTrait.php
index fba3826..f19771d 100644
--- a/tests/Integration/Support/CreatesUsersTrait.php
+++ b/tests/Integration/Support/CreatesUsersTrait.php
@@ -2,6 +2,8 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Support;
+use PHPUnit\Framework\Attributes\After;
+
trait CreatesUsersTrait {
protected function make_user( string $login, string $role ): int {
$existing = get_user_by( 'login', $login );
@@ -21,4 +23,10 @@ protected function make_user( string $login, string $role ): int {
return $user_id;
}
+
+ // Rows run in random order, so a row that logged a user in must not leave them logged in for the next one.
+ #[After]
+ protected function log_out(): void {
+ wp_set_current_user( 0 );
+ }
}
From 93d46c0da17bd4397f3aed3dcccd75bb39b95dd5 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 18:59:30 +0100
Subject: [PATCH 13/23] Name the entries limitation 5 covers
Limitation 5 said the entry's catch takes a partial bootstrap
package's Error, but only consumer-a's entry requires load.php inside
its try, while the personas and consumer-b require it outside one and
fatal at include; and it said the uninstall shim keeps the data, which
holds for a web request, while WP-CLI fails and keeps the files.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 344a080..2007300 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -116,7 +116,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 cancel Action Scheduler's default store cannot write repeats until the request's time limit; 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 entry's catch shows the build notice, or rethrows in local and development environments, and the uninstall shim keeps the data, except on PHP 7.4, where a failed `require` is fatal at include. 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, consumer-a's entry, which requires it inside its one `try`, shows the build notice, or rethrows in local and development environments, while an entry that requires it outside a `try` fatals at include; the uninstall shim keeps the data on a web request, while WP-CLI fails and keeps the files. On PHP 7.4 a failed `require` is fatal at include either way. 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. A database write that keeps failing keeps Action Scheduler's bulk cancel looping until the request's time limit. 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. It reads each notice aloud through `wp.a11y.speak()`, which drops text between angle brackets, so a literal angle-bracketed placeholder in a message goes unspoken. 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.
From 5c9c97fa7c8ee02bd00d8bef491379254a1c8d85 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 19:06:23 +0100
Subject: [PATCH 14/23] Drop the logouts the user trait already runs
Once the user trait logged the current user out after every row, the
storage, object form and WooCommerce settings tests still did it in
their own teardown, and four programmatic-update rows did it again in
their finally, so three places owned one reset.
Those copies go. The admin-screen trait keeps its own, since it logs
users in itself.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Integration/Core/AdminNotice/AdminNoticesStorageTest.php | 5 -----
.../Settings/EditScreen/Hosts/PostMetaBoxTest.php | 1 -
tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php | 1 -
.../Settings/EditScreen/Hosts/UserProfileTest.php | 1 -
tests/Integration/Settings/EditScreen/ObjectFormTest.php | 1 -
.../WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php | 1 -
tests/Integration/WooCommerce/Setting/SettingsTest.php | 1 -
7 files changed, 11 deletions(-)
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
index aab3e60..a89c9a8 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
@@ -31,11 +31,6 @@ protected function setUp(): void {
wp_set_current_user( $this->user_id );
}
- #[\Override]
- protected function tearDown(): void {
- wp_set_current_user( 0 );
- }
-
public function test_each_scope_keeps_its_notices_as_one_array_by_id_in_its_home(): void {
global $wpdb;
$user = array( $this->notice( 'one' ), $this->notice( 'two' ) );
diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
index d2990fe..9bb3251 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
@@ -556,7 +556,6 @@ public function test_a_save_without_the_forms_nonce_stores_nothing(): void {
);
} finally {
unset( $_POST[ $this->id ] );
- wp_set_current_user( 0 );
}
self::assertSame( 'Updated in code', get_the_title( $post_id ) );
diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
index a3f76a1..02a576d 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
@@ -159,7 +159,6 @@ public function test_an_update_in_code_stores_nothing(): void {
wp_update_term( $term_id, 'category', array( 'name' => "$this->prefix in code" ) );
} finally {
unset( $_POST[ $this->id ] );
- wp_set_current_user( 0 );
}
self::assertSame( "$this->prefix in code", get_term( $term_id )->name );
diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
index 3308729..551a3f4 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
@@ -217,7 +217,6 @@ public function test_an_update_in_code_stores_nothing(): void {
);
} finally {
unset( $_POST[ $this->id ] );
- wp_set_current_user( 0 );
}
self::assertSame( 'Updated in code', get_userdata( $this->subscriber )->display_name );
diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
index 1b7e0cb..9d38b81 100644
--- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php
+++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
@@ -52,7 +52,6 @@ protected function setUp(): void {
protected function tearDown(): void {
$_POST = $this->post_backup;
wp_delete_post( $this->post_id, true );
- wp_set_current_user( 0 );
}
public static function foreign_nonces(): array {
diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
index 52dc75e..e410eb3 100644
--- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
+++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
@@ -350,7 +350,6 @@ public function test_an_order_saved_in_code_stores_nothing(): void {
$order->save();
} finally {
unset( $_POST[ "{$this->id}_nonce" ], $_POST[ $this->id ] );
- wp_set_current_user( 0 );
}
self::assertSame( 'Updated in code', wc_get_order( $order->get_id() )->get_customer_note() );
diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php
index 9a80110..45033b5 100644
--- a/tests/Integration/WooCommerce/Setting/SettingsTest.php
+++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php
@@ -56,7 +56,6 @@ protected function tearDown(): void {
SettingsSectionRegistry::get_instance()->unregister_all();
$this->reset_settings_pages();
$GLOBALS['wp_rest_server'] = null;
- wp_set_current_user( 0 );
}
public static function silent_and_worded_rejections(): array {
From 48fdebc4dd3127d4734c2066e97fc35e958a7c5f Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 19:49:00 +0100
Subject: [PATCH 15/23] Expect built files without an asset file to register
Core's Assets required the .asset.php named after each built file, but
wp-scripts writes none for the style-{entry}.css chunk that a style.scss
import becomes, keeping its version in the entry's file, so registering
such a stylesheet threw on every request; a file built outside
wp-scripts failed the same way.
The rows register a committed split stylesheet and a committed script
without asset files, and expect each versioned by its modification
time, the script with no dependencies but the i18n one its
translations add.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Fixtures/Core/assets/build/style-editor.css | 1 +
tests/Fixtures/Core/assets/build/vendor.js | 1 +
tests/Integration/Core/Asset/AssetsTest.php | 14 ++++++++++++++
3 files changed, 16 insertions(+)
create mode 100644 tests/Fixtures/Core/assets/build/style-editor.css
create mode 100644 tests/Fixtures/Core/assets/build/vendor.js
diff --git a/tests/Fixtures/Core/assets/build/style-editor.css b/tests/Fixtures/Core/assets/build/style-editor.css
new file mode 100644
index 0000000..b66b564
--- /dev/null
+++ b/tests/Fixtures/Core/assets/build/style-editor.css
@@ -0,0 +1 @@
+.acme-editor { color: inherit; }
diff --git a/tests/Fixtures/Core/assets/build/vendor.js b/tests/Fixtures/Core/assets/build/vendor.js
new file mode 100644
index 0000000..2aa595e
--- /dev/null
+++ b/tests/Fixtures/Core/assets/build/vendor.js
@@ -0,0 +1 @@
+window.acmeVendor = true;
diff --git a/tests/Integration/Core/Asset/AssetsTest.php b/tests/Integration/Core/Asset/AssetsTest.php
index f4e2179..1dda3d6 100644
--- a/tests/Integration/Core/Asset/AssetsTest.php
+++ b/tests/Integration/Core/Asset/AssetsTest.php
@@ -42,6 +42,20 @@ public function test_a_style_registers_with_its_build_s_version_and_a_right_to_l
self::assertSame( 'replace', $style->extra['rtl'] ?? null );
}
+ public function test_a_style_chunk_without_its_own_asset_file_is_versioned_by_its_modification_time(): void {
+ self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_style( self::STYLE, 'assets/build/style-editor.css' ) );
+
+ self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/style-editor.css' ), wp_styles()->registered[ self::STYLE ]->ver );
+ }
+
+ public function test_a_script_built_without_an_asset_file_registers_without_dependencies_at_its_modification_time(): void {
+ self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_script( self::SCRIPT, 'assets/build/vendor.js' ) );
+
+ $script = wp_scripts()->registered[ self::SCRIPT ];
+ self::assertSame( array( 'wp-i18n' ), $script->deps );
+ self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/vendor.js' ), $script->ver );
+ }
+
public function test_a_handle_registered_elsewhere_keeps_its_script_and_gains_no_translations(): void {
wp_register_script( self::SCRIPT, 'https://example.com/other.js', array(), '1', true );
From 4946331d3afdd8cb83be906d5ff93e5709dbae7d Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 19:49:36 +0100
Subject: [PATCH 16/23] Version a built file without an asset file by its time
Assets required the .asset.php named after each built file, so the
style-{entry}.css chunk wp-scripts makes of a style.scss import, whose
version sits in the entry's asset file, threw on every request that
registered it, as did any file built outside wp-scripts.
Where a built file has no asset file of its own, Assets registers it
with its modification time as its version, and a script then with no
dependencies, as v1 versioned every asset. Only wp-scripts builds are
supported; the fallback is a best effort. The core README says so.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/README.md | 2 +-
packages/core/src/Asset/Assets.php | 15 +++++++++++++--
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/packages/core/README.md b/packages/core/README.md
index 432052c..502bac9 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -189,7 +189,7 @@ final class Greeting implements ComponentInterface {
}
```
-`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $plugin_file, $slug )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant.
+`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $plugin_file, $slug )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant. It supports wp-scripts builds: a built file without its own `.asset.php`, such as the `style-{entry}.css` chunk of a `style.scss` import, registers with its modification time as its version, and a script then with no dependencies, as a best effort.
The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data:
diff --git a/packages/core/src/Asset/Assets.php b/packages/core/src/Asset/Assets.php
index e810215..8cd6ad0 100644
--- a/packages/core/src/Asset/Assets.php
+++ b/packages/core/src/Asset/Assets.php
@@ -82,7 +82,9 @@ public function register_style( string $handle, string $path ): bool {
// region HELPERS
/**
- * Returns the dependencies and version that wp-scripts writes beside a built file.
+ * Returns the dependencies and version that wp-scripts writes beside a built file, or no dependencies and the file's modification time where it wrote none.
+ *
+ * The style-{entry}.css chunk that wp-scripts makes of a style.scss import has no asset file, and neither has a file built outside wp-scripts, so such a file is versioned by its own time, as a best effort.
*
* @since 2.0.0
* @version 2.0.0
@@ -92,12 +94,21 @@ public function register_style( string $handle, string $path ): bool {
* @return array{dependencies: list, version: string}
*/
protected function get_asset( string $path ): array {
+ $file = plugin_dir_path( $this->plugin_file ) . $path;
+ $asset_file = \dirname( $file ) . '/' . \pathinfo( $file, \PATHINFO_FILENAME ) . '.asset.php';
+ if ( ! \is_readable( $asset_file ) ) {
+ return array(
+ 'dependencies' => array(),
+ 'version' => (string) \filemtime( $file ),
+ );
+ }
+
/**
* The build's dependencies and version.
*
* @var array{dependencies: list, version: string} $asset
*/
- $asset = require plugin_dir_path( $this->plugin_file ) . \dirname( $path ) . '/' . \pathinfo( $path, \PATHINFO_FILENAME ) . '.asset.php';
+ $asset = require $asset_file;
return $asset;
}
From 057264aaad80197331efd5a6197923f148ec697f Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 19:52:58 +0100
Subject: [PATCH 17/23] Expect a style chunk to take its entry's version
The modification-time fallback also caught the style-{entry}.css chunk
that wp-scripts makes of a style.scss import, though wp-scripts writes
its version, a hash of its content, into the entry's asset file beside
it, so a supported build's stylesheet was versioned only as a best
effort.
The split stylesheet's row now expects the entry's version. A
stylesheet without any asset file keeps the modification time, and a
script named like a style chunk takes no entry's asset file, since
wp-scripts splits only stylesheets that way.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Fixtures/Core/assets/build/style-editor.js | 1 +
tests/Fixtures/Core/assets/build/vendor.css | 1 +
tests/Integration/Core/Asset/AssetsTest.php | 18 ++++++++++++++++--
3 files changed, 18 insertions(+), 2 deletions(-)
create mode 100644 tests/Fixtures/Core/assets/build/style-editor.js
create mode 100644 tests/Fixtures/Core/assets/build/vendor.css
diff --git a/tests/Fixtures/Core/assets/build/style-editor.js b/tests/Fixtures/Core/assets/build/style-editor.js
new file mode 100644
index 0000000..7225e98
--- /dev/null
+++ b/tests/Fixtures/Core/assets/build/style-editor.js
@@ -0,0 +1 @@
+window.acmeStyleEditor = true;
diff --git a/tests/Fixtures/Core/assets/build/vendor.css b/tests/Fixtures/Core/assets/build/vendor.css
new file mode 100644
index 0000000..e55079f
--- /dev/null
+++ b/tests/Fixtures/Core/assets/build/vendor.css
@@ -0,0 +1 @@
+.acme-vendor { color: inherit; }
diff --git a/tests/Integration/Core/Asset/AssetsTest.php b/tests/Integration/Core/Asset/AssetsTest.php
index 1dda3d6..1d29705 100644
--- a/tests/Integration/Core/Asset/AssetsTest.php
+++ b/tests/Integration/Core/Asset/AssetsTest.php
@@ -42,10 +42,24 @@ public function test_a_style_registers_with_its_build_s_version_and_a_right_to_l
self::assertSame( 'replace', $style->extra['rtl'] ?? null );
}
- public function test_a_style_chunk_without_its_own_asset_file_is_versioned_by_its_modification_time(): void {
+ public function test_the_style_chunk_of_an_entry_takes_the_entry_s_version(): void {
self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_style( self::STYLE, 'assets/build/style-editor.css' ) );
- self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/style-editor.css' ), wp_styles()->registered[ self::STYLE ]->ver );
+ self::assertSame( 'a1b2c3', wp_styles()->registered[ self::STYLE ]->ver );
+ }
+
+ public function test_a_stylesheet_without_an_asset_file_is_versioned_by_its_modification_time(): void {
+ self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_style( self::STYLE, 'assets/build/vendor.css' ) );
+
+ self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/vendor.css' ), wp_styles()->registered[ self::STYLE ]->ver );
+ }
+
+ public function test_a_script_named_like_a_style_chunk_takes_no_entry_s_asset_file(): void {
+ self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_script( self::SCRIPT, 'assets/build/style-editor.js' ) );
+
+ $script = wp_scripts()->registered[ self::SCRIPT ];
+ self::assertSame( array( 'wp-i18n' ), $script->deps );
+ self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/style-editor.js' ), $script->ver );
}
public function test_a_script_built_without_an_asset_file_registers_without_dependencies_at_its_modification_time(): void {
From 652bdddcd73ee6623bd575f2d8c36736519b48f2 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 19:53:19 +0100
Subject: [PATCH 18/23] Read a style chunk's version from its entry's asset
file
The modification-time fallback versioned the style-{entry}.css chunk
that wp-scripts makes of a style.scss import, though wp-scripts writes
that chunk's version, a hash of its content, into the entry's asset
file beside it, so a supported build got a best-effort version that
changes with every copy of the file.
A stylesheet named style-{entry}.css without an asset file of its own
reads the entry's. Only a file with neither, which wp-scripts did not
build, falls back to its modification time.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/README.md | 2 +-
packages/core/src/Asset/Assets.php | 11 ++++++++---
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/packages/core/README.md b/packages/core/README.md
index 502bac9..b9da361 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -189,7 +189,7 @@ final class Greeting implements ComponentInterface {
}
```
-`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $plugin_file, $slug )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant. It supports wp-scripts builds: a built file without its own `.asset.php`, such as the `style-{entry}.css` chunk of a `style.scss` import, registers with its modification time as its version, and a script then with no dependencies, as a best effort.
+`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $plugin_file, $slug )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported: a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version, and a script then with no dependencies, as a best effort.
The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data:
diff --git a/packages/core/src/Asset/Assets.php b/packages/core/src/Asset/Assets.php
index 8cd6ad0..e398b3c 100644
--- a/packages/core/src/Asset/Assets.php
+++ b/packages/core/src/Asset/Assets.php
@@ -82,9 +82,9 @@ public function register_style( string $handle, string $path ): bool {
// region HELPERS
/**
- * Returns the dependencies and version that wp-scripts writes beside a built file, or no dependencies and the file's modification time where it wrote none.
+ * Returns the dependencies and version that wp-scripts writes for a built file, or no dependencies and the file's modification time where it wrote none.
*
- * The style-{entry}.css chunk that wp-scripts makes of a style.scss import has no asset file, and neither has a file built outside wp-scripts, so such a file is versioned by its own time, as a best effort.
+ * The style-{entry}.css chunk that wp-scripts makes of a style.scss import has no asset file of its own, since its version sits in the entry's. A file built outside wp-scripts has none at all, so it is versioned by its own time, as a best effort.
*
* @since 2.0.0
* @version 2.0.0
@@ -95,7 +95,12 @@ public function register_style( string $handle, string $path ): bool {
*/
protected function get_asset( string $path ): array {
$file = plugin_dir_path( $this->plugin_file ) . $path;
- $asset_file = \dirname( $file ) . '/' . \pathinfo( $file, \PATHINFO_FILENAME ) . '.asset.php';
+ $name = \pathinfo( $file, \PATHINFO_FILENAME );
+ $asset_file = \dirname( $file ) . "/$name.asset.php";
+ if ( ! \is_readable( $asset_file ) && \str_starts_with( $name, 'style-' ) && \str_ends_with( $file, '.css' ) ) {
+ $asset_file = \dirname( $file ) . '/' . \substr( $name, \strlen( 'style-' ) ) . '.asset.php';
+ }
+
if ( ! \is_readable( $asset_file ) ) {
return array(
'dependencies' => array(),
From b9978a7bd730478a4d07d7077d0e71d1616dbb41 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 19:55:46 +0100
Subject: [PATCH 19/23] Resolve the built file's directory once
The asset lookup computed the built file's directory for its own asset
file and again for its entry's.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/src/Asset/Assets.php | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/core/src/Asset/Assets.php b/packages/core/src/Asset/Assets.php
index e398b3c..23bb0d2 100644
--- a/packages/core/src/Asset/Assets.php
+++ b/packages/core/src/Asset/Assets.php
@@ -95,10 +95,11 @@ public function register_style( string $handle, string $path ): bool {
*/
protected function get_asset( string $path ): array {
$file = plugin_dir_path( $this->plugin_file ) . $path;
+ $directory = \dirname( $file );
$name = \pathinfo( $file, \PATHINFO_FILENAME );
- $asset_file = \dirname( $file ) . "/$name.asset.php";
+ $asset_file = "$directory/$name.asset.php";
if ( ! \is_readable( $asset_file ) && \str_starts_with( $name, 'style-' ) && \str_ends_with( $file, '.css' ) ) {
- $asset_file = \dirname( $file ) . '/' . \substr( $name, \strlen( 'style-' ) ) . '.asset.php';
+ $asset_file = "$directory/" . \substr( $name, \strlen( 'style-' ) ) . '.asset.php';
}
if ( ! \is_readable( $asset_file ) ) {
From 097d8f4c139134df6a5e33e85fb42287db12051d Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 20:55:23 +0100
Subject: [PATCH 20/23] Expect one plugin identity in place of its strings
A plugin handed the framework its entry file, slug and prefix as loose
strings in pairs and triples, so a swapped pair, such as the prefix
where the slug belonged, went unnoticed and rooted hooks in the wrong
name, and every class repeated the rule that turns a slug into a hook
root. The names the framework stores, such as the runner's revision
option, the grants' ledger and the scheduler's group, were the plugin's
to choose, though every consumer already built them from its prefix.
The tests and fixtures now pass one PluginIdentity: the storage trait
and the migration fixtures give each row a fresh one, the reference
plugins build theirs once in the composition root and define it in the
container, and components that build a surface take it by constructor.
The rows expect the names the framework derives from the prefix, the
slug as every log channel, and the prefix check in the identity, which
a new unit test covers, while AdminNotices keeps its reserved segments.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Fixtures/Settings/acme-object-forms.php | 6 +-
tests/Fixtures/Settings/acme-options-page.php | 3 +-
.../WooCommerce/acme-product-data.php | 3 +-
tests/Fixtures/WooCommerce/acme-uninstall.php | 3 +-
tests/Fixtures/consumer-a/src/Plugin.php | 30 +++++--
tests/Fixtures/consumer-a/src/ProductData.php | 19 ++++-
tests/Fixtures/consumer-a/uninstall.php | 5 +-
tests/Fixtures/consumer-b/src/Plugin.php | 26 +++++--
tests/Fixtures/consumer-b/src/Settings.php | 19 ++++-
tests/Fixtures/end-to-end/acme-end-to-end.php | 8 +-
tests/Fixtures/personas/core/src/Plugin.php | 28 +++++--
tests/Fixtures/personas/core/uninstall.php | 5 +-
.../settings/acme-persona-settings.php | 2 +
.../Fixtures/personas/settings/functions.php | 8 +-
.../personas/woocommerce/functions.php | 10 ++-
.../personas/woocommerce/uninstall.php | 5 +-
.../AdminNoticesBlockEditorTest.php | 2 +-
.../AdminNotice/AdminNoticesDismissalTest.php | 6 +-
.../AdminNotice/AdminNoticesStorageTest.php | 2 +-
.../Core/AdminNotice/AdminNoticesTest.php | 33 ++++----
tests/Integration/Core/Asset/AssetsTest.php | 15 ++--
tests/Integration/Core/Caching/CachesTest.php | 2 +-
.../Core/Caching/ObjectCacheTest.php | 32 ++++----
.../Core/Caching/TransientCacheTest.php | 78 ++++++++-----------
.../Core/Logging/ErrorLogLoggerTest.php | 17 ++--
.../Core/Logging/GuardedLoggerTest.php | 7 +-
.../Core/Migration/MigrationFixturesTrait.php | 13 ++--
.../MigrationRunnerInLocalEnvironmentTest.php | 2 +-
.../Core/Migration/MigrationRunnerTest.php | 32 ++++----
.../Core/Permission/CapabilityGrantsTest.php | 45 +++++------
.../Core/PluginBootInLocalEnvironmentTest.php | 2 +-
tests/Integration/Core/PluginBootTest.php | 6 +-
.../SchedulerOnSwitchedSiteTest.php | 3 +-
.../Core/Scheduling/SchedulerTest.php | 40 +---------
tests/Integration/Core/Storage/LockTest.php | 19 ++---
.../Core/Uninstall/UninstallFixturesTrait.php | 5 ++
.../Uninstall/UninstallerOnNetworkTest.php | 20 ++---
.../Core/Uninstall/UninstallerTest.php | 44 +++++------
.../UninstallerWithoutActionSchedulerTest.php | 2 +-
.../Settings/AdminPage/OptionsPageTest.php | 8 +-
.../EditScreen/Hosts/PostMetaBoxTest.php | 13 ++--
.../EditScreen/Hosts/TermFormTest.php | 6 +-
.../EditScreen/Hosts/UserProfileTest.php | 6 +-
.../Settings/EditScreen/ObjectFormTest.php | 18 ++---
.../Support/IsolatesStorageTrait.php | 4 +
.../EditScreen/Hosts/OrderMetaBoxTest.php | 8 +-
.../Logging/WooCommerceLoggerTest.php | 7 +-
...ooCommerceLoggerWithoutWooCommerceTest.php | 3 +-
.../ProductData/ProductDataTabTest.php | 10 +--
.../WooCommerce/Uninstall/MetaTypesTest.php | 2 +-
.../MetaTypesWithoutWooCommerceTest.php | 2 +-
.../WooCommerceRequirementTest.php | 9 ++-
...merceRequirementWithoutWooCommerceTest.php | 3 +-
.../Unit/Core/Logging/ErrorLogLoggerTest.php | 3 +-
tests/Unit/Core/PluginIdentityTest.php | 40 ++++++++++
55 files changed, 425 insertions(+), 324 deletions(-)
create mode 100644 tests/Unit/Core/PluginIdentityTest.php
diff --git a/tests/Fixtures/Settings/acme-object-forms.php b/tests/Fixtures/Settings/acme-object-forms.php
index 38ba416..00963de 100644
--- a/tests/Fixtures/Settings/acme-object-forms.php
+++ b/tests/Fixtures/Settings/acme-object-forms.php
@@ -9,6 +9,7 @@
use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox;
use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\TermForm;
use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\UserProfile;
@@ -66,9 +67,10 @@ public function update_values( int $object_id, array $values ): void {
};
}
- $notices = new AdminNotices( $spec['slug'], $spec['id'], new ErrorLogLogger( 'acme' ) );
+ $identity = new PluginIdentity( __FILE__, $spec['slug'], $spec['id'] );
+ $notices = new AdminNotices( $identity, new ErrorLogLogger( $identity ) );
$notices->register_hooks();
- new ObjectForm( $spec['slug'], $spec['id'], $definition, $host, $notices, $store )->register_hooks();
+ new ObjectForm( $identity, $spec['id'], $definition, $host, $notices, $store )->register_hooks();
}
);
diff --git a/tests/Fixtures/Settings/acme-options-page.php b/tests/Fixtures/Settings/acme-options-page.php
index 3dbb44a..aa2c191 100644
--- a/tests/Fixtures/Settings/acme-options-page.php
+++ b/tests/Fixtures/Settings/acme-options-page.php
@@ -7,6 +7,7 @@
* @package Acme
*/
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage;
use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition;
@@ -24,7 +25,7 @@
'plugins_loaded',
static function () use ( $spec, $acme_with_behaviors ): void {
$page = new OptionsPage(
- $spec['slug'],
+ new PluginIdentity( __FILE__, $spec['slug'], 'acme' ),
$spec['id'],
new RecordedDefinition( static fn (): array => array( 'tabs' => $acme_with_behaviors( $spec['page']['tabs'] ) ) + $spec['page'] )
);
diff --git a/tests/Fixtures/WooCommerce/acme-product-data.php b/tests/Fixtures/WooCommerce/acme-product-data.php
index d5ea3af..3e72a22 100644
--- a/tests/Fixtures/WooCommerce/acme-product-data.php
+++ b/tests/Fixtures/WooCommerce/acme-product-data.php
@@ -7,6 +7,7 @@
* @package Acme
*/
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition;
use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab;
@@ -43,7 +44,7 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec ): ?array {
}
);
- new ProductDataTab( $spec['slug'], $spec['id'], $definition )->register_hooks();
+ new ProductDataTab( new PluginIdentity( __FILE__, $spec['slug'], 'acme' ), $spec['id'], $definition )->register_hooks();
}
);
diff --git a/tests/Fixtures/WooCommerce/acme-uninstall.php b/tests/Fixtures/WooCommerce/acme-uninstall.php
index 2bc7f7d..74af2c2 100644
--- a/tests/Fixtures/WooCommerce/acme-uninstall.php
+++ b/tests/Fixtures/WooCommerce/acme-uninstall.php
@@ -5,6 +5,7 @@
* @package Acme
*/
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Uninstall\Uninstaller;
use DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes;
@@ -17,7 +18,7 @@
static function () use ( $spec ): void {
$GLOBALS['acme_outcome']['records']['woocommerce_loaded'] = class_exists( 'WooCommerce', false );
Uninstaller::run(
- $spec['plugin_file'],
+ new PluginIdentity( $spec['plugin_file'], 'acme-uninstall', 'acme' ),
$spec['footprint'],
array(
'consent' => $spec['consent'],
diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php
index 38d7b2f..9465716 100644
--- a/tests/Fixtures/consumer-a/src/Plugin.php
+++ b/tests/Fixtures/consumer-a/src/Plugin.php
@@ -5,6 +5,7 @@
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginBoot;
+use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginKernel;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface;
@@ -129,6 +130,16 @@ final class Plugin {
*/
protected readonly PsrLoggerInterface $logger;
+ /**
+ * The plugin's identity, which every framework class the root builds takes.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var PluginIdentity
+ */
+ protected readonly PluginIdentity $identity;
+
// endregion
// region MAGIC METHODS
@@ -140,9 +151,10 @@ final class Plugin {
* @version 1.0.0
*/
public function __construct() {
- $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'ACME_CA_DEBUG' ) && true === \constant( 'ACME_CA_DEBUG' ) ? 'debug' : null );
- $this->scheduler = new Scheduler( self::PREFIX );
- $this->boundary = new PluginBoot( ACME_CA_FILE, self::SLUG, self::PREFIX, $this->logger );
+ $this->identity = new PluginIdentity( ACME_CA_FILE, self::SLUG, self::PREFIX );
+ $this->logger = new WooCommerceLogger( $this->identity, \defined( 'ACME_CA_DEBUG' ) && true === \constant( 'ACME_CA_DEBUG' ) ? 'debug' : null );
+ $this->scheduler = new Scheduler( $this->identity );
+ $this->boundary = new PluginBoot( $this->identity, $this->logger );
}
// endregion
@@ -175,14 +187,14 @@ public function boot(): void {
*/
protected function compose(): bool {
$this->scheduler->register_hooks(); // Before the WooCommerce check, so the plugin's recurring WP-Cron events keep their schedules while it is blocked.
- if ( ! WooCommerceRequirement::check( ACME_CA_FILE ) ) {
+ if ( ! WooCommerceRequirement::check( $this->identity ) ) {
return false;
}
- $this->migrations = new MigrationRunner( ACME_CA_FILE, self::SLUG, self::PREFIX . '_revision', Migrations::get_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
- $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
+ $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() );
$this->kernel->boot( self::COMPONENTS );
return true;
@@ -194,17 +206,19 @@ protected function compose(): bool {
* @since 1.0.0
* @version 1.0.0
*
+ * @param PluginIdentity $identity The plugin's identity.
* @param PsrLoggerInterface $logger The plugin's logger.
* @param Scheduler $scheduler The plugin's scheduler.
*
* @return array
*/
- protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array {
+ protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger, Scheduler $scheduler ): array {
return array(
+ PluginIdentity::class => static fn (): PluginIdentity => $identity,
PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger,
Scheduler::class => static fn (): Scheduler => $scheduler,
SchedulerInterface::class => static fn (): Scheduler => $scheduler,
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ),
);
}
diff --git a/tests/Fixtures/consumer-a/src/ProductData.php b/tests/Fixtures/consumer-a/src/ProductData.php
index 059a6c5..85b8ec1 100644
--- a/tests/Fixtures/consumer-a/src/ProductData.php
+++ b/tests/Fixtures/consumer-a/src/ProductData.php
@@ -3,6 +3,7 @@
namespace Acme\ConsumerA;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface;
+use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTabDefinitionInterface;
@@ -29,6 +30,22 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI
// endregion
+ // region MAGIC METHODS
+
+ /**
+ * Constructor.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @param PluginIdentity $identity The plugin's identity, which the tab takes.
+ */
+ public function __construct(
+ protected PluginIdentity $identity
+ ) {}
+
+ // endregion
+
// region INHERITED METHODS
/**
@@ -39,7 +56,7 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI
*/
#[\Override]
public function register_hooks(): void {
- new ProductDataTab( Plugin::SLUG, Plugin::PREFIX . '_packing', $this )->register_hooks();
+ new ProductDataTab( $this->identity, Plugin::PREFIX . '_packing', $this )->register_hooks();
}
/**
diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php
index 2adf079..deb4230 100644
--- a/tests/Fixtures/consumer-a/uninstall.php
+++ b/tests/Fixtures/consumer-a/uninstall.php
@@ -11,6 +11,7 @@
use Acme\ConsumerA\Options;
use Acme\ConsumerA\Plugin;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements;
+use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller;
use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes;
@@ -35,6 +36,8 @@
if ( ! class_exists( Uninstaller::class ) ) {
throw new RuntimeException( 'its uninstall engine is missing.' );
}
+
+ $acme_ca_identity = new PluginIdentity( $acme_ca_file, Plugin::SLUG, Plugin::PREFIX );
} catch ( Throwable $error ) {
// WP-CLI then fails and keeps the files for a rerun on a complete build, while a web request deletes them and leaves the data.
if ( defined( 'WP_CLI' ) && true === WP_CLI ) {
@@ -47,7 +50,7 @@
try {
Uninstaller::run(
- $acme_ca_file,
+ $acme_ca_identity,
$acme_ca_footprint,
array(
'consent' => array(
diff --git a/tests/Fixtures/consumer-b/src/Plugin.php b/tests/Fixtures/consumer-b/src/Plugin.php
index 19e70df..981f738 100644
--- a/tests/Fixtures/consumer-b/src/Plugin.php
+++ b/tests/Fixtures/consumer-b/src/Plugin.php
@@ -5,6 +5,7 @@
use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginBoot;
+use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginKernel;
use Acme\ConsumerB\Scoped\DI\ContainerBuilder;
use Acme\ConsumerB\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface;
@@ -83,6 +84,16 @@ final class Plugin {
*/
protected readonly PsrLoggerInterface $logger;
+ /**
+ * The plugin's identity, which every framework class the root builds takes.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var PluginIdentity
+ */
+ protected readonly PluginIdentity $identity;
+
// endregion
// region MAGIC METHODS
@@ -94,8 +105,9 @@ final class Plugin {
* @version 1.0.0
*/
public function __construct() {
- $this->logger = new ErrorLogLogger( self::SLUG );
- $this->boundary = new PluginBoot( ACME_CB_FILE, self::SLUG, self::PREFIX, $this->logger );
+ $this->identity = new PluginIdentity( ACME_CB_FILE, self::SLUG, self::PREFIX );
+ $this->logger = new ErrorLogLogger( $this->identity );
+ $this->boundary = new PluginBoot( $this->identity, $this->logger );
}
// endregion
@@ -127,7 +139,7 @@ public function boot(): void {
* @return bool
*/
protected function compose(): bool {
- $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger ) )->build() );
+ $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger ) )->build() );
$this->kernel->boot( self::COMPONENTS );
return true;
@@ -139,13 +151,15 @@ protected function compose(): bool {
* @since 1.0.0
* @version 1.0.0
*
- * @param PsrLoggerInterface $logger The plugin's logger.
+ * @param PluginIdentity $identity The plugin's identity.
+ * @param PsrLoggerInterface $logger The plugin's logger.
*
* @return array
*/
- protected function get_definitions( PsrLoggerInterface $logger ): array {
+ protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger ): array {
return array(
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
+ PluginIdentity::class => static fn (): PluginIdentity => $identity,
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ),
);
}
diff --git a/tests/Fixtures/consumer-b/src/Settings.php b/tests/Fixtures/consumer-b/src/Settings.php
index 1524b88..e7fdeb1 100644
--- a/tests/Fixtures/consumer-b/src/Settings.php
+++ b/tests/Fixtures/consumer-b/src/Settings.php
@@ -3,6 +3,7 @@
namespace Acme\ConsumerB;
use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface;
+use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage;
use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPageDefinitionInterface;
@@ -15,6 +16,22 @@
* @version 1.0.0
*/
final class Settings implements ComponentInterface, OptionsPageDefinitionInterface {
+ // region MAGIC METHODS
+
+ /**
+ * Constructor.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @param PluginIdentity $identity The plugin's identity, which the page takes.
+ */
+ public function __construct(
+ protected PluginIdentity $identity
+ ) {}
+
+ // endregion
+
// region INHERITED METHODS
/**
@@ -25,7 +42,7 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa
*/
#[\Override]
public function register_hooks(): void {
- new OptionsPage( Plugin::SLUG, Plugin::PREFIX, $this )->register_hooks();
+ new OptionsPage( $this->identity, Plugin::PREFIX, $this )->register_hooks();
}
/**
diff --git a/tests/Fixtures/end-to-end/acme-end-to-end.php b/tests/Fixtures/end-to-end/acme-end-to-end.php
index bce4162..231b5d9 100644
--- a/tests/Fixtures/end-to-end/acme-end-to-end.php
+++ b/tests/Fixtures/end-to-end/acme-end-to-end.php
@@ -12,6 +12,7 @@
use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope;
use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType;
use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox;
use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm;
use DeepWebSolutions\Framework\Settings\EditScreen\ObjectFormDefinitionInterface;
@@ -19,7 +20,8 @@
// wp-env maps the repository to wp-content/mu-plugins/wp-framework and this directory into the plugins directory.
require_once WPMU_PLUGIN_DIR . '/wp-framework/vendor/autoload.php';
-$acme_notices = new AdminNotices( 'acme-e2e', 'acme_e2e', new ErrorLogLogger( 'acme-end-to-end' ) );
+$acme_identity = new PluginIdentity( __FILE__, 'acme-e2e', 'acme_e2e' );
+$acme_notices = new AdminNotices( $acme_identity, new ErrorLogLogger( $acme_identity ) );
register_activation_hook(
__FILE__,
@@ -37,7 +39,7 @@ static function () use ( $acme_notices ): void {
add_action(
'plugins_loaded',
- static function () use ( $acme_notices ): void {
+ static function () use ( $acme_identity, $acme_notices ): void {
$acme_notices->register_hooks();
$definition = new class() implements ObjectFormDefinitionInterface {
@@ -56,7 +58,7 @@ public function get_object_form( string $form_id, int $object_id ): array {
);
}
};
- new ObjectForm( 'acme-e2e', 'acme_e2e_details', $definition, new PostMetaBox( array( 'post' ) ), $acme_notices )->register_hooks();
+ new ObjectForm( $acme_identity, 'acme_e2e_details', $definition, new PostMetaBox( array( 'post' ) ), $acme_notices )->register_hooks();
}
);
diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php
index 6068adb..ad67182 100644
--- a/tests/Fixtures/personas/core/src/Plugin.php
+++ b/tests/Fixtures/personas/core/src/Plugin.php
@@ -6,6 +6,7 @@
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot;
+use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginKernel;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface;
@@ -124,6 +125,16 @@ final class Plugin {
*/
protected readonly PsrLoggerInterface $logger;
+ /**
+ * The plugin's identity, which every framework class the root builds takes.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var PluginIdentity
+ */
+ protected readonly PluginIdentity $identity;
+
// endregion
// region MAGIC METHODS
@@ -135,9 +146,10 @@ final class Plugin {
* @version 1.0.0
*/
public function __construct() {
- $this->logger = new ErrorLogLogger( self::SLUG );
- $this->scheduler = new Scheduler( self::PREFIX );
- $this->boundary = new PluginBoot( ACME_PC_FILE, self::SLUG, self::PREFIX, $this->logger );
+ $this->identity = new PluginIdentity( ACME_PC_FILE, self::SLUG, self::PREFIX );
+ $this->logger = new ErrorLogLogger( $this->identity );
+ $this->scheduler = new Scheduler( $this->identity );
+ $this->boundary = new PluginBoot( $this->identity, $this->logger );
}
// endregion
@@ -169,10 +181,10 @@ public function boot(): void {
* @return bool
*/
protected function compose(): bool {
- $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', Migrations::get_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
- $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
+ $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() );
$this->kernel->boot( self::COMPONENTS );
return true;
@@ -184,17 +196,19 @@ protected function compose(): bool {
* @since 1.0.0
* @version 1.0.0
*
+ * @param PluginIdentity $identity The plugin's identity.
* @param PsrLoggerInterface $logger The plugin's logger.
* @param Scheduler $scheduler The plugin's scheduler.
*
* @return array
*/
- protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array {
+ protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger, Scheduler $scheduler ): array {
return array(
+ PluginIdentity::class => static fn (): PluginIdentity => $identity,
PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger,
Scheduler::class => static fn (): Scheduler => $scheduler,
SchedulerInterface::class => static fn (): Scheduler => $scheduler,
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ),
);
}
diff --git a/tests/Fixtures/personas/core/uninstall.php b/tests/Fixtures/personas/core/uninstall.php
index cc4e083..a16410f 100644
--- a/tests/Fixtures/personas/core/uninstall.php
+++ b/tests/Fixtures/personas/core/uninstall.php
@@ -11,6 +11,7 @@
use Acme\PersonaCore\Options;
use Acme\PersonaCore\Plugin;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements;
+use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller;
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
@@ -33,6 +34,8 @@
if ( ! class_exists( Uninstaller::class ) ) {
throw new RuntimeException( 'its uninstall engine is missing.' );
}
+
+ $acme_pc_identity = new PluginIdentity( $acme_pc_file, Plugin::SLUG, Plugin::PREFIX );
} catch ( Throwable $error ) {
// WP-CLI then fails and keeps the files for a rerun on a complete build, while a web request deletes them and leaves the data.
if ( defined( 'WP_CLI' ) && true === WP_CLI ) {
@@ -44,7 +47,7 @@
}
Uninstaller::run(
- $acme_pc_file,
+ $acme_pc_identity,
$acme_pc_footprint,
array(
'consent' => array(
diff --git a/tests/Fixtures/personas/settings/acme-persona-settings.php b/tests/Fixtures/personas/settings/acme-persona-settings.php
index 6c013e2..8924ca4 100644
--- a/tests/Fixtures/personas/settings/acme-persona-settings.php
+++ b/tests/Fixtures/personas/settings/acme-persona-settings.php
@@ -22,6 +22,8 @@
defined( 'ABSPATH' ) || exit;
+define( 'ACME_PS_FILE', __FILE__ );
+
$acme_ps_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php';
if ( ! is_readable( $acme_ps_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it.
return;
diff --git a/tests/Fixtures/personas/settings/functions.php b/tests/Fixtures/personas/settings/functions.php
index c74d0f8..a916053 100644
--- a/tests/Fixtures/personas/settings/functions.php
+++ b/tests/Fixtures/personas/settings/functions.php
@@ -12,6 +12,7 @@
use Acme\PersonaSettings\Settings;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
+use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm;
@@ -21,11 +22,12 @@
add_action(
'plugins_loaded',
static function (): void {
- $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
+ $identity = new PluginIdentity( ACME_PS_FILE, Settings::SLUG, Settings::PREFIX );
+ $notices = new AdminNotices( $identity, new ErrorLogLogger( $identity ) );
$notices->register_hooks();
- new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
- new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
+ new OptionsPage( $identity, Settings::PREFIX, new Settings() )->register_hooks();
+ new ObjectForm( $identity, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
},
15
);
diff --git a/tests/Fixtures/personas/woocommerce/functions.php b/tests/Fixtures/personas/woocommerce/functions.php
index 1db6b34..f623526 100644
--- a/tests/Fixtures/personas/woocommerce/functions.php
+++ b/tests/Fixtures/personas/woocommerce/functions.php
@@ -9,6 +9,7 @@
*/
use Acme\PersonaWooCommerce\Packing;
+use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab;
@@ -19,11 +20,12 @@
add_action(
'plugins_loaded',
static function (): void {
- if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) {
+ $identity = new PluginIdentity( ACME_PW_FILE, Packing::SLUG, Packing::PREFIX );
+ if ( ! WooCommerceRequirement::check( $identity ) ) {
return;
}
- new ProductDataTab( Packing::SLUG, Packing::PREFIX, new Packing() )->register_hooks();
+ new ProductDataTab( $identity, Packing::PREFIX, new Packing() )->register_hooks();
add_filter(
'woocommerce_get_settings_pages',
static function ( array $pages ): array {
@@ -42,8 +44,8 @@ static function ( array $pages ): array {
);
add_action(
'woocommerce_update_options_' . Packing::PREFIX,
- static function (): void {
- new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' );
+ static function () use ( $identity ): void {
+ new WooCommerceLogger( $identity, 'info' )->info( 'The packing settings were saved.' );
}
);
},
diff --git a/tests/Fixtures/personas/woocommerce/uninstall.php b/tests/Fixtures/personas/woocommerce/uninstall.php
index 60f146d..8ed9a4f 100644
--- a/tests/Fixtures/personas/woocommerce/uninstall.php
+++ b/tests/Fixtures/personas/woocommerce/uninstall.php
@@ -11,6 +11,7 @@
use Acme\PersonaWooCommerce\Options;
use Acme\PersonaWooCommerce\Packing;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements;
+use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes;
@@ -35,6 +36,8 @@
if ( ! class_exists( Uninstaller::class ) ) {
throw new RuntimeException( 'its uninstall engine is missing.' );
}
+
+ $acme_pw_identity = new PluginIdentity( $acme_pw_file, Packing::SLUG, Packing::PREFIX );
} catch ( Throwable $error ) {
// WP-CLI then fails and keeps the files for a rerun on a complete build, while a web request deletes them and leaves the data.
if ( defined( 'WP_CLI' ) && true === WP_CLI ) {
@@ -46,7 +49,7 @@
}
Uninstaller::run(
- $acme_pw_file,
+ $acme_pw_identity,
$acme_pw_footprint,
array(
'consent' => array( 'option' => $acme_pw_consent ),
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php
index 6a1da0d..61104a2 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php
@@ -206,7 +206,7 @@ protected function deliver(): \WP_REST_Response {
}
protected function notices(): AdminNotices {
- return new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
+ return new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) );
}
protected function script_handle(): string {
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php
index 76aa006..c42077d 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php
@@ -39,7 +39,7 @@ protected function setUp(): void {
$this->admin = $this->make_user( 'acme_notices_admin', 'administrator' );
$this->other_admin = $this->make_user( 'acme_notices_other_admin', 'administrator' );
$this->logger = new RecordingLogger();
- $this->notices = new AdminNotices( $this->slug, $this->prefix, $this->logger );
+ $this->notices = new AdminNotices( $this->identity, $this->logger );
add_action(
"$this->hook_root/admin_notice/dismissed",
function ( mixed ...$arguments ): void {
@@ -271,7 +271,7 @@ public function test_a_failing_store_answers_500_and_is_logged( bool $per_viewer
}
public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches_the_error_log(): void {
- $notices = new AdminNotices( $this->slug, $this->prefix, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) );
+ $notices = new AdminNotices( $this->identity, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) );
$notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) );
$request = new \WP_REST_Request( 'POST' );
$request->set_body_params( $this->shown( $this->admin, 'site', 'x' ) );
@@ -289,7 +289,7 @@ public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches
self::assertInstanceOf( \WP_Error::class, $response );
self::assertSame( array( 'status' => 500 ), $response->get_error_data() );
- self::assertStringContainsString( "[$this->prefix.error] Could not dismiss a {scope} notice: {message} {\"scope\":\"site\",\"message\":", $log );
+ self::assertStringContainsString( "[$this->slug.error] Could not dismiss a {scope} notice: {message} {\"scope\":\"site\",\"message\":", $log );
self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log );
}
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
index a89c9a8..3402ddc 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php
@@ -27,7 +27,7 @@ final class AdminNoticesStorageTest extends TestCase {
#[\Override]
protected function setUp(): void {
$this->user_id = $this->make_user( 'acme_notices_admin', 'administrator' );
- $this->notices = new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
+ $this->notices = new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) );
wp_set_current_user( $this->user_id );
}
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
index 11eb6fa..813b9ba 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
@@ -5,6 +5,7 @@
use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope;
use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use DeepWebSolutions\Framework\Shared\Exception\LogicException;
use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait;
@@ -43,31 +44,27 @@ protected function setUp(): void {
$this->logger = new RecordingLogger();
}
- public static function invalid_prefixes(): array {
+ public static function reserved_prefixes(): array {
return array(
- 'empty' => array( '' ),
- 'uppercase' => array( 'Acme' ),
- 'leading-digit' => array( '1acme' ),
- 'hyphen' => array( 'ac-me' ),
- 'trailing-newline' => array( "acme\n" ),
- '33-characters' => array( 'a' . \str_repeat( 'b', 32 ) ),
- 'hidden-segment' => array( 'acme_hidden' ),
- 'network-segment' => array( 'acme_network_tools' ),
- 'leading-hidden' => array( 'hidden_acme' ),
- 'only-network' => array( 'network' ),
+ 'hidden-segment' => array( 'acme_hidden' ),
+ 'network-segment' => array( 'acme_network_tools' ),
+ 'leading-hidden' => array( 'hidden_acme' ),
+ 'only-network' => array( 'network' ),
);
}
- #[DataProvider( 'invalid_prefixes' )]
- public function test_a_prefix_outside_the_pattern_or_with_a_reserved_segment_is_rejected( string $prefix ): void {
+ #[DataProvider( 'reserved_prefixes' )]
+ public function test_a_prefix_with_a_reserved_segment_is_rejected( string $prefix ): void {
+ $identity = new PluginIdentity( $this->identity->file, $this->slug, $prefix );
+
$this->expectException( InvalidArgumentException::class );
- new AdminNotices( $this->slug, $prefix, $this->logger );
+ new AdminNotices( $identity, $this->logger );
}
- public function test_a_prefix_may_hold_the_reserved_words_inside_longer_segments_and_reach_32_characters(): void {
- foreach ( array( 'acme_notice', 'acme_notices', 'acmenetwork_x', 'acme_hiddenx', 'a' . \str_repeat( 'b', 31 ) ) as $prefix ) {
- $notices = new AdminNotices( $this->slug, $prefix, $this->logger );
+ public function test_a_prefix_may_hold_the_reserved_words_inside_longer_segments(): void {
+ foreach ( array( 'acme_notice', 'acme_notices', 'acmenetwork_x', 'acme_hiddenx' ) as $prefix ) {
+ $notices = new AdminNotices( new PluginIdentity( $this->identity->file, $this->slug, $prefix ), $this->logger );
self::assertSame( array(), $notices->get_notices( NoticeScope::Request ) );
}
}
@@ -464,7 +461,7 @@ public function test_a_network_notice_shows_on_every_site_and_in_the_network_adm
}
protected function notices(): AdminNotices {
- return new AdminNotices( $this->slug, $this->prefix, $this->logger );
+ return new AdminNotices( $this->identity, $this->logger );
}
// A fresh instance stands for the next request.
diff --git a/tests/Integration/Core/Asset/AssetsTest.php b/tests/Integration/Core/Asset/AssetsTest.php
index 1d29705..6e8a7da 100644
--- a/tests/Integration/Core/Asset/AssetsTest.php
+++ b/tests/Integration/Core/Asset/AssetsTest.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Asset;
use DeepWebSolutions\Framework\Core\Asset\Assets;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -21,7 +22,7 @@ protected function tearDown(): void {
}
public function test_a_script_registers_with_its_build_s_script_dependencies_version_and_translations(): void {
- self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_script( self::SCRIPT, 'assets/build/editor.js', array( 'in_footer' => true ) ) );
+ self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_script( self::SCRIPT, 'assets/build/editor.js', array( 'in_footer' => true ) ) );
$script = wp_scripts()->registered[ self::SCRIPT ];
self::assertSame( plugin_dir_url( self::PLUGIN_FILE ) . 'assets/build/editor.js', $script->src );
@@ -33,7 +34,7 @@ public function test_a_script_registers_with_its_build_s_script_dependencies_ver
}
public function test_a_style_registers_with_its_build_s_version_and_a_right_to_left_variant(): void {
- self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_style( self::STYLE, 'assets/build/editor.css' ) );
+ self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/editor.css' ) );
$style = wp_styles()->registered[ self::STYLE ];
self::assertSame( plugin_dir_url( self::PLUGIN_FILE ) . 'assets/build/editor.css', $style->src );
@@ -43,19 +44,19 @@ public function test_a_style_registers_with_its_build_s_version_and_a_right_to_l
}
public function test_the_style_chunk_of_an_entry_takes_the_entry_s_version(): void {
- self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_style( self::STYLE, 'assets/build/style-editor.css' ) );
+ self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/style-editor.css' ) );
self::assertSame( 'a1b2c3', wp_styles()->registered[ self::STYLE ]->ver );
}
public function test_a_stylesheet_without_an_asset_file_is_versioned_by_its_modification_time(): void {
- self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_style( self::STYLE, 'assets/build/vendor.css' ) );
+ self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/vendor.css' ) );
self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/vendor.css' ), wp_styles()->registered[ self::STYLE ]->ver );
}
public function test_a_script_named_like_a_style_chunk_takes_no_entry_s_asset_file(): void {
- self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_script( self::SCRIPT, 'assets/build/style-editor.js' ) );
+ self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_script( self::SCRIPT, 'assets/build/style-editor.js' ) );
$script = wp_scripts()->registered[ self::SCRIPT ];
self::assertSame( array( 'wp-i18n' ), $script->deps );
@@ -63,7 +64,7 @@ public function test_a_script_named_like_a_style_chunk_takes_no_entry_s_asset_fi
}
public function test_a_script_built_without_an_asset_file_registers_without_dependencies_at_its_modification_time(): void {
- self::assertTrue( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_script( self::SCRIPT, 'assets/build/vendor.js' ) );
+ self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_script( self::SCRIPT, 'assets/build/vendor.js' ) );
$script = wp_scripts()->registered[ self::SCRIPT ];
self::assertSame( array( 'wp-i18n' ), $script->deps );
@@ -73,7 +74,7 @@ public function test_a_script_built_without_an_asset_file_registers_without_depe
public function test_a_handle_registered_elsewhere_keeps_its_script_and_gains_no_translations(): void {
wp_register_script( self::SCRIPT, 'https://example.com/other.js', array(), '1', true );
- self::assertFalse( new Assets( self::PLUGIN_FILE, 'acme-plugin' )->register_script( self::SCRIPT, 'assets/build/editor.js' ) );
+ self::assertFalse( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_script( self::SCRIPT, 'assets/build/editor.js' ) );
self::assertSame( 'https://example.com/other.js', wp_scripts()->registered[ self::SCRIPT ]->src );
self::assertNull( wp_scripts()->registered[ self::SCRIPT ]->textdomain );
}
diff --git a/tests/Integration/Core/Caching/CachesTest.php b/tests/Integration/Core/Caching/CachesTest.php
index 1ea96f8..5633a5d 100644
--- a/tests/Integration/Core/Caching/CachesTest.php
+++ b/tests/Integration/Core/Caching/CachesTest.php
@@ -489,7 +489,7 @@ protected static function cross( array $rows ): array {
}
protected function cache( string $kind, string $name = 'cache' ): PsrCacheInterface {
- return 'transient' === $kind ? new TransientCache( "{$this->prefix}_{$name}_" ) : new ObjectCache( "{$this->prefix}_$name" );
+ return 'transient' === $kind ? new TransientCache( $this->identity, $name ) : new ObjectCache( $this->identity, $name );
}
protected function generation( string $kind ): ?string {
diff --git a/tests/Integration/Core/Caching/ObjectCacheTest.php b/tests/Integration/Core/Caching/ObjectCacheTest.php
index 4822247..2d22a30 100644
--- a/tests/Integration/Core/Caching/ObjectCacheTest.php
+++ b/tests/Integration/Core/Caching/ObjectCacheTest.php
@@ -113,33 +113,33 @@ protected function restore_the_object_cache(): void {
wp_cache_flush();
}
- public static function invalid_groups(): array {
+ public static function invalid_regions(): array {
return array(
- 'empty' => array( '' ),
- 'zero' => array( '0' ),
- 'digit-first' => array( '1acme' ),
- 'underscore-first' => array( '_acme' ),
- 'uppercase' => array( 'Acme_cache' ),
+ 'uppercase' => array( 'Cache' ),
'colon' => array( 'acme:cache' ),
'space' => array( 'acme cache' ),
'dot' => array( 'acme.cache' ),
- 'leading-space' => array( ' acme' ),
- 'trailing-newline' => array( "acme\n" ),
- 'overlong' => array( 'a' . \str_repeat( 'b', 64 ) ),
+ 'trailing-newline' => array( "cache\n" ),
);
}
- #[DataProvider( 'invalid_groups' )]
- public function test_a_group_that_is_not_a_lowercase_identifier_of_up_to_64_characters_is_rejected( string $group ): void {
+ #[DataProvider( 'invalid_regions' )]
+ public function test_a_region_that_makes_the_group_no_lowercase_identifier_is_rejected( string $region ): void {
$this->expectException( InvalidArgumentException::class );
- new ObjectCache( $group );
+ new ObjectCache( $this->identity, $region );
+ }
+
+ // The prefix, an underscore and the region fill the group's 64 characters.
+ public function test_a_group_past_64_characters_is_rejected(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ new ObjectCache( $this->identity, \str_repeat( 'r', 64 - \strlen( $this->prefix ) ) );
}
public function test_a_valid_group_is_accepted_without_touching_the_object_cache(): void {
- new ObjectCache( 'a' );
- new ObjectCache( 'a' . \str_repeat( 'b', 63 ) );
- new ObjectCache( 'acme_cache-2' );
+ new ObjectCache( $this->identity, 'cache-2' );
+ new ObjectCache( $this->identity, \str_repeat( 'r', 63 - \strlen( $this->prefix ) ) );
self::assertSame( array(), $this->spy->calls );
}
@@ -391,7 +391,7 @@ public function test_empty_batches_and_reads_without_a_salt_touch_no_entry(): vo
}
protected function cache(): ObjectCache {
- return new ObjectCache( $this->group() );
+ return new ObjectCache( $this->identity, 'cache' );
}
protected function group(): string {
diff --git a/tests/Integration/Core/Caching/TransientCacheTest.php b/tests/Integration/Core/Caching/TransientCacheTest.php
index ab90311..9647ffa 100644
--- a/tests/Integration/Core/Caching/TransientCacheTest.php
+++ b/tests/Integration/Core/Caching/TransientCacheTest.php
@@ -20,44 +20,30 @@ final class TransientCacheTest extends TestCase {
use IsolatesHooksTrait;
use IsolatesStorageTrait;
- public static function invalid_prefixes(): array {
- return array(
- 'empty' => array( '' ),
- 'leading' => array( ' acme_' ),
- 'trailing' => array( 'acme_ ' ),
- 'overlong' => array( \str_repeat( 'a', 141 ) ),
- );
- }
+ // The prefix, the region and an underscore after each fill the 140 characters that leave room for a key's hash.
+ public function test_a_region_that_leaves_no_room_for_a_keys_hash_is_rejected(): void {
+ new TransientCache( $this->identity, \str_repeat( 'r', 138 - \strlen( $this->prefix ) ) );
- #[DataProvider( 'invalid_prefixes' )]
- public function test_a_prefix_the_transient_names_would_not_keep_is_rejected( string $prefix ): void {
$this->expectException( InvalidArgumentException::class );
- new TransientCache( $prefix );
+ new TransientCache( $this->identity, \str_repeat( 'r', 139 - \strlen( $this->prefix ) ) );
}
- public static function invalid_generation_options(): array {
- return array(
- 'empty' => array( '' ),
- 'leading' => array( ' acme_generation' ),
- 'trailing' => array( 'acme_generation ' ),
- 'overlong' => array( \str_repeat( 'a', 192 ) ),
- );
- }
+ // The prefix, an underscore, the generation and _cache_generation fill the 191 characters of an option name.
+ public function test_a_generation_whose_option_name_would_pass_191_characters_is_rejected(): void {
+ new TransientCache( $this->identity, 'cache', \str_repeat( 'g', 173 - \strlen( $this->prefix ) ) );
- #[DataProvider( 'invalid_generation_options' )]
- public function test_a_generation_option_the_options_table_would_not_keep_is_rejected( string $option ): void {
$this->expectException( InvalidArgumentException::class );
- new TransientCache( "{$this->prefix}_", $option );
+ new TransientCache( $this->identity, 'cache', \str_repeat( 'g', 174 - \strlen( $this->prefix ) ) );
}
public function test_construction_reads_and_writes_nothing(): void {
global $wpdb;
$queries = $wpdb->num_queries;
- new TransientCache( \str_pad( "{$this->prefix}_", 140, 'p' ) );
- new TransientCache( "{$this->prefix}_", \str_pad( "{$this->prefix}_generation", 191, 'g' ) );
+ new TransientCache( $this->identity, \str_repeat( 'r', 138 - \strlen( $this->prefix ) ) );
+ new TransientCache( $this->identity, 'cache', \str_repeat( 'g', 173 - \strlen( $this->prefix ) ) );
self::assertSame( $queries, $wpdb->num_queries );
self::assertSame( array(), $this->stored() );
@@ -99,10 +85,10 @@ public function test_each_entry_is_a_transient_named_by_the_hash_of_its_key_with
$this->cache()->set( 'key', 'Value.' );
$this->cache()->set( $long, 'Value.' );
- $expected = array( "{$this->prefix}_cache_generation" => 'on' );
+ $expected = array( "{$this->prefix}_cache_cache_generation" => 'on' );
foreach ( array( 'key', $long ) as $key ) {
- $expected[ "_transient_{$this->prefix}_" . \md5( $key ) ] = 'off';
- $expected[ "_transient_timeout_{$this->prefix}_" . \md5( $key ) ] = 'off';
+ $expected[ "_transient_{$this->prefix}_cache_" . \md5( $key ) ] = 'off';
+ $expected[ "_transient_timeout_{$this->prefix}_cache_" . \md5( $key ) ] = 'off';
}
\ksort( $expected );
@@ -110,7 +96,7 @@ public function test_each_entry_is_a_transient_named_by_the_hash_of_its_key_with
}
public function test_a_filtered_scalar_reads_as_a_miss(): void {
- add_filter( "pre_transient_{$this->prefix}_" . \md5( 'key' ), static fn (): string => 'Filtered.' );
+ add_filter( "pre_transient_{$this->prefix}_cache_" . \md5( 'key' ), static fn (): string => 'Filtered.' );
self::assertSame( 'Default.', $this->cache()->get( 'key', 'Default.' ) );
}
@@ -140,7 +126,7 @@ public function test_an_entry_whose_timeout_has_passed_reads_as_the_default(): v
$cache = $this->cache();
$cache->set( 'key', 'Value.', HOUR_IN_SECONDS );
- $wpdb->update( $wpdb->options, array( 'option_value' => \time() - 1 ), array( 'option_name' => "_transient_timeout_{$this->prefix}_" . \md5( 'key' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Expires the entry without waiting.
+ $wpdb->update( $wpdb->options, array( 'option_value' => \time() - 1 ), array( 'option_name' => "_transient_timeout_{$this->prefix}_cache_" . \md5( 'key' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Expires the entry without waiting.
wp_cache_flush();
self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) );
@@ -188,7 +174,7 @@ public function test_a_failed_delete_returns_true_because_wordpress_reads_the_en
public function test_a_delete_that_leaves_the_timeout_row_returns_true(): void {
$cache = $this->cache();
$cache->set( 'key', 'Value.' );
- $this->fail_queries( "_transient_timeout_{$this->prefix}_" . \md5( 'key' ) );
+ $this->fail_queries( "_transient_timeout_{$this->prefix}_cache_" . \md5( 'key' ) );
self::assertTrue( $cache->delete( 'key' ) );
self::assertFalse( $cache->has( 'key' ) );
@@ -206,7 +192,7 @@ public function test_behind_a_persistent_object_cache_the_same_results_hold(): v
self::assertTrue( $cache->delete( 'key' ) );
self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) );
self::assertTrue( $cache->set( 'key', 'Value.', 0 ) );
- self::assertSame( array( "{$this->prefix}_cache_generation" => 'on' ), $this->stored() );
+ self::assertSame( array( "{$this->prefix}_cache_cache_generation" => 'on' ), $this->stored() );
} finally {
// The flag is null without a drop-in, and passing null only reads it.
$GLOBALS['_wp_using_ext_object_cache'] = $previous;
@@ -257,14 +243,14 @@ public function test_each_clear_stores_a_new_random_autoloaded_generation_even_o
$cache = $this->cache();
self::assertTrue( $cache->clear() );
- $first = get_option( "{$this->prefix}_cache_generation" );
+ $first = get_option( "{$this->prefix}_cache_cache_generation" );
self::assertTrue( $cache->clear() );
- $second = get_option( "{$this->prefix}_cache_generation" );
+ $second = get_option( "{$this->prefix}_cache_cache_generation" );
self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', $first );
self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', $second );
self::assertNotSame( $first, $second );
- self::assertSame( array( "{$this->prefix}_cache_generation" => 'on' ), $this->stored() );
+ self::assertSame( array( "{$this->prefix}_cache_cache_generation" => 'on' ), $this->stored() );
}
public static function unusable_generations(): array {
@@ -276,9 +262,9 @@ public static function unusable_generations(): array {
#[DataProvider( 'unusable_generations' )]
public function test_a_generation_option_holding_no_non_empty_string_reads_as_none_until_a_write_starts_one( mixed $stored ): void {
- update_option( "{$this->prefix}_cache_generation", $stored );
+ update_option( "{$this->prefix}_cache_cache_generation", $stored );
set_transient(
- "{$this->prefix}_" . \md5( 'key' ),
+ "{$this->prefix}_cache_" . \md5( 'key' ),
array(
'value' => 'Value.',
'generation' => $stored,
@@ -289,13 +275,13 @@ public function test_a_generation_option_holding_no_non_empty_string_reads_as_no
self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) );
self::assertTrue( $cache->set( 'other', 'Other.' ) );
- self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', get_option( "{$this->prefix}_cache_generation" ) );
+ self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', get_option( "{$this->prefix}_cache_cache_generation" ) );
self::assertSame( 'Other.', $cache->get( 'other' ) );
}
public function test_without_a_generation_every_read_misses_without_a_write(): void {
set_transient(
- "{$this->prefix}_" . \md5( 'key' ),
+ "{$this->prefix}_cache_" . \md5( 'key' ),
array(
'value' => 'Value.',
'generation' => null,
@@ -314,7 +300,7 @@ public function test_without_a_generation_every_read_misses_without_a_write(): v
public function test_an_entry_from_before_the_generation_was_deleted_never_comes_back(): void {
$cache = $this->cache();
$cache->set( 'old', 'Old.' );
- delete_option( "{$this->prefix}_cache_generation" );
+ delete_option( "{$this->prefix}_cache_cache_generation" );
$cache->set( 'new', 'New.' );
@@ -338,7 +324,7 @@ public function test_reading_several_keys_from_the_options_table_runs_one_query(
public function test_a_failed_write_or_delete_among_several_returns_false(): void {
$cache = $this->cache();
$cache->setMultiple( \array_fill_keys( array( 'a', 'b' ), 'Prior.' ) );
- $this->fail_queries( "_transient_{$this->prefix}_" . \md5( 'a' ) );
+ $this->fail_queries( "_transient_{$this->prefix}_cache_" . \md5( 'a' ) );
self::assertFalse( $cache->setMultiple( \array_fill_keys( array( 'a', 'b' ), 'Newer.' ) ) );
self::assertSame( 'Newer.', $cache->get( 'b' ) );
@@ -346,9 +332,9 @@ public function test_a_failed_write_or_delete_among_several_returns_false(): voi
self::assertFalse( $cache->has( 'b' ) );
}
- public function test_caches_that_share_a_generation_option_clear_together(): void {
- $one = new TransientCache( "{$this->prefix}_one_", "{$this->prefix}_shared_generation" );
- $two = new TransientCache( "{$this->prefix}_two_", "{$this->prefix}_shared_generation" );
+ public function test_caches_that_share_a_generation_clear_together(): void {
+ $one = new TransientCache( $this->identity, 'one', 'shared' );
+ $two = new TransientCache( $this->identity, 'two', 'shared' );
$one->set( 'key', 'One.' );
$two->set( 'key', 'Two.' );
@@ -361,7 +347,7 @@ public function test_caches_that_share_a_generation_option_clear_together(): voi
// Returning the stored value from this filter makes update_option() report a failed write, as a refused database write would.
public function test_a_generation_that_cannot_be_stored_fails_writes_and_clears(): void {
- add_filter( "pre_update_option_{$this->prefix}_cache_generation", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 );
+ add_filter( "pre_update_option_{$this->prefix}_cache_cache_generation", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 );
$cache = $this->cache();
self::assertFalse( $cache->set( 'key', 'Value.' ) );
@@ -395,13 +381,13 @@ public function delete( $key, $group = 'default', $deprecated = false ) {
}
protected function cache(): TransientCache {
- return new TransientCache( "{$this->prefix}_" );
+ return new TransientCache( $this->identity, 'cache' );
}
protected function timeout( string $key ): int {
global $wpdb;
- return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "_transient_timeout_{$this->prefix}_" . \md5( $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Inspects the stored timeout.
+ return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "_transient_timeout_{$this->prefix}_cache_" . \md5( $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Inspects the stored timeout.
}
protected function stored(): array {
diff --git a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php
index 44a18df..5b08a80 100644
--- a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php
+++ b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Logging;
use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait;
use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesLogMessagesTrait;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -49,7 +50,7 @@ public static function levels(): array {
public function test_every_level_reaches_the_error_log_under_wp_debug( string $level ): void {
self::assertTrue( WP_DEBUG );
- new ErrorLogLogger( 'acme' )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) );
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) );
self::assertStringContainsString( "[acme.$level] Cache acme is cold.", (string) \file_get_contents( $this->log_file ) );
}
@@ -66,14 +67,14 @@ static function ( mixed ...$args ) use ( &$received ): void {
2
);
- new ErrorLogLogger( 'acme' )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) );
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) );
self::assertSame( array( array( 'Cache {name} is cold.', array( 'name' => 'acme' ) ) ), $received );
}
#[DataProvider( 'log_messages' )]
public function test_a_record_interpolates_only_printable_context_values( string $message, array $context, string $expected ): void {
- new ErrorLogLogger( 'acme' )->error( $message, $context );
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( $message, $context );
self::assertStringContainsString( "[acme.error] $expected" . \PHP_EOL, (string) \file_get_contents( $this->log_file ) );
}
@@ -90,7 +91,7 @@ public function __toString(): string {
}
};
- new ErrorLogLogger( 'acme' )->error(
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error(
'Hello {name}.',
array(
'name' => 'world',
@@ -105,7 +106,7 @@ public function __toString(): string {
public function test_an_exception_in_the_context_is_logged_with_its_class_location_and_trace(): void {
$exception = new \RuntimeException( 'Cache is cold.' );
- new ErrorLogLogger( 'acme' )->error(
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error(
'Warm-up failed: {message}',
array(
'message' => $exception->getMessage(),
@@ -122,13 +123,13 @@ public function test_an_exception_in_the_context_is_logged_with_its_class_locati
public function test_a_previous_exception_is_logged_with_the_exception(): void {
$exception = new \RuntimeException( 'Warm-up failed.', 0, new \LogicException( 'Cache is cold.' ) );
- new ErrorLogLogger( 'acme' )->error( 'Warm-up failed.', array( 'exception' => $exception ) );
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( 'Warm-up failed.', array( 'exception' => $exception ) );
self::assertStringContainsString( 'LogicException: Cache is cold. in ' . __FILE__, (string) \file_get_contents( $this->log_file ) );
}
public function test_an_exception_context_value_that_is_not_a_throwable_is_ignored(): void {
- new ErrorLogLogger( 'acme' )->error( 'Warm-up failed.', array( 'exception' => 'Cache is cold.' ) );
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( 'Warm-up failed.', array( 'exception' => 'Cache is cold.' ) );
self::assertStringNotContainsString( 'Cache is cold.', (string) \file_get_contents( $this->log_file ) );
}
@@ -137,6 +138,6 @@ public function test_a_level_psr_3_does_not_define_is_rejected(): void {
$this->expectException( PsrInvalidArgumentException::class );
$this->expectExceptionMessage( "Log level 'verbose' is not a PSR-3 level." );
- new ErrorLogLogger( 'acme' )->log( 'verbose', 'Cache is cold.' );
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->log( 'verbose', 'Cache is cold.' );
}
}
diff --git a/tests/Integration/Core/Logging/GuardedLoggerTest.php b/tests/Integration/Core/Logging/GuardedLoggerTest.php
index 7b5ee4d..5162ce6 100644
--- a/tests/Integration/Core/Logging/GuardedLoggerTest.php
+++ b/tests/Integration/Core/Logging/GuardedLoggerTest.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Logging;
use DeepWebSolutions\Framework\Core\Logging\GuardedLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger;
use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -31,7 +32,7 @@ protected function tearDown(): void {
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' ) );
+ new GuardedLogger( $inner, new PluginIdentity( __FILE__, 'acme', '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 ) );
@@ -41,7 +42,7 @@ public function test_a_record_whose_logger_throws_reaches_the_error_log_raw_with
$exception = new \RuntimeException( 'Cache is cold.' );
$failure = new \UnexpectedValueException( 'The log is not writable.' );
- new GuardedLogger( new ThrowingLogger( $failure ), 'acme' )->error(
+ new GuardedLogger( new ThrowingLogger( $failure ), new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error(
'Warm-up of {name} failed.',
array(
'name' => 'acme',
@@ -58,7 +59,7 @@ public function test_a_level_psr_3_does_not_define_throws_before_the_wrapped_log
$inner = new RecordingLogger();
try {
- new GuardedLogger( $inner, 'acme' )->log( 'verbose', 'Cache is cold.' );
+ new GuardedLogger( $inner, new PluginIdentity( __FILE__, 'acme', '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() );
diff --git a/tests/Integration/Core/Migration/MigrationFixturesTrait.php b/tests/Integration/Core/Migration/MigrationFixturesTrait.php
index 0829d1e..98bf7fe 100644
--- a/tests/Integration/Core/Migration/MigrationFixturesTrait.php
+++ b/tests/Integration/Core/Migration/MigrationFixturesTrait.php
@@ -2,23 +2,24 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Migration;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Storage\OptionRows;
use PHPUnit\Framework\Attributes\After;
use PHPUnit\Framework\Attributes\Before;
trait MigrationFixturesTrait {
- protected string $option;
+ protected PluginIdentity $identity;
- protected string $slug;
+ protected string $option;
protected string $hook_root;
- // The slug differs from the option, so a row tells the runner's hook from its stored names.
+ // The slug differs from the prefix, so a row tells the runner's hook from its stored names.
#[Before]
- protected function use_a_fresh_option(): void {
+ protected function use_a_fresh_identity(): void {
$suffix = \bin2hex( \random_bytes( 4 ) );
- $this->option = "acme_db_$suffix";
- $this->slug = "acme-runner-$suffix";
+ $this->identity = new PluginIdentity( \dirname( __DIR__, 3 ) . '/Fixtures/Core/acme-boot.php', "acme-runner-$suffix", "acme_db_$suffix" );
+ $this->option = "acme_db_{$suffix}_revision";
$this->hook_root = "acme_runner_$suffix";
}
diff --git a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php
index 200f5cf..2eb5420 100644
--- a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php
+++ b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php
@@ -83,6 +83,6 @@ function (): void {
}
protected function make_runner( array $steps ): MigrationRunner {
- return new MigrationRunner( __DIR__ . '/../../../Fixtures/Core/acme-boot.php', $this->slug, $this->option, $steps, new PsrNullLogger(), new Scheduler( 'acme' ) );
+ return new MigrationRunner( $this->identity, $steps, new PsrNullLogger(), new Scheduler( $this->identity ) );
}
}
diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php
index c2b8ebf..e79a9e4 100644
--- a/tests/Integration/Core/Migration/MigrationRunnerTest.php
+++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php
@@ -4,6 +4,7 @@
use DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface;
use DeepWebSolutions\Framework\Core\Migration\MigrationRunner;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Scheduling\Scheduler;
use DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
@@ -100,17 +101,10 @@ public function test_steps_below_the_stored_revision_never_run(): void {
}
public function test_a_slug_whose_scheduled_hook_would_pass_191_bytes_is_refused(): void {
- new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 177 ), $this->option, array(), $this->logger, $this->make_wp_cron_scheduler() );
+ new MigrationRunner( new PluginIdentity( self::PLUGIN_FILE, \str_repeat( 'a', 177 ), $this->identity->prefix ), array(), $this->logger, $this->make_wp_cron_scheduler() );
$this->expectException( InvalidArgumentException::class );
- new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 178 ), $this->option, array(), $this->logger, $this->make_wp_cron_scheduler() );
- }
-
- public function test_an_option_whose_high_water_mark_would_pass_191_characters_is_refused(): void {
- new MigrationRunner( self::PLUGIN_FILE, $this->slug, \str_repeat( 'a', 180 ), array(), $this->logger, $this->make_wp_cron_scheduler() );
-
- $this->expectException( InvalidArgumentException::class );
- new MigrationRunner( self::PLUGIN_FILE, $this->slug, \str_repeat( 'a', 181 ), array(), $this->logger, $this->make_wp_cron_scheduler() );
+ new MigrationRunner( new PluginIdentity( self::PLUGIN_FILE, \str_repeat( 'a', 178 ), $this->identity->prefix ), array(), $this->logger, $this->make_wp_cron_scheduler() );
}
public static function current_revisions(): array {
@@ -646,7 +640,7 @@ static function (): void {
wp_delete_file( $log_file );
}
- self::assertStringContainsString( "[$this->option.error] Data migration failed: {message} {\"message\":\"Step failed with a broken logger.\"}\nRuntimeException: Step failed with a broken logger. in ", $log );
+ self::assertStringContainsString( "[{$this->identity->slug}.error] Data migration failed: {message} {\"message\":\"Step failed with a broken logger.\"}\nRuntimeException: Step failed with a broken logger. in ", $log );
self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log );
self::assertIsArray( get_option( "{$this->option}_error" ) );
}
@@ -673,13 +667,13 @@ function ( string $query ): string {
$wpdb->suppress_errors( $suppressed );
}
- self::assertStringContainsString( "[$this->option.warning] Could not take the data migration lock: {error} {\"error\":\"", $log );
+ self::assertStringContainsString( "[{$this->identity->slug}.warning] Could not take the data migration lock: {error} {\"error\":\"", $log );
self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log );
self::assertSame( array(), $this->ran );
}
public function test_a_batch_time_limit_below_1_second_runs_one_step_per_request(): void {
- $this->load_wordpress( new MigrationRunner( self::PLUGIN_FILE, $this->slug, $this->option, array( $this->step( 1 ), $this->step( 2 ) ), $this->logger, $this->make_wp_cron_scheduler(), 0 ) );
+ $this->load_wordpress( new MigrationRunner( $this->identity, array( $this->step( 1 ), $this->step( 2 ) ), $this->logger, $this->make_wp_cron_scheduler(), 0 ) );
self::assertSame( array( array( 1, 0 ) ), $this->ran );
self::assertSame( 1, MigrationRunner::get_revision( $this->option ) );
@@ -701,7 +695,7 @@ public function test_a_request_outside_the_runner_hook_runs_plain_steps_but_no_b
}
public function test_a_request_outside_the_runner_hook_adds_nothing_while_the_hook_runs_elsewhere(): void {
- as_enqueue_async_action( "$this->hook_root/migration/run", array(), 'acme' );
+ as_enqueue_async_action( "$this->hook_root/migration/run", array(), $this->identity->prefix );
$running = as_get_scheduled_actions(
array(
'hook' => "$this->hook_root/migration/run",
@@ -711,7 +705,7 @@ public function test_a_request_outside_the_runner_hook_adds_nothing_while_the_ho
);
\ActionScheduler::store()->log_execution( (int) $running[0] );
- $this->load_wordpress( $this->make_runner( array( new RecordedBatches( 3 ) ), null, null, new Scheduler( 'acme' ) ) );
+ $this->load_wordpress( $this->make_runner( array( new RecordedBatches( 3 ) ), null, null, new Scheduler( $this->identity ) ) );
self::assertSame( 0, $this->count_pending_runs() );
}
@@ -972,7 +966,7 @@ public static function dispatchers(): array {
public function test_a_batched_step_finishes_through_real_scheduler_dispatch( string $kind ): void {
$clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) );
$batches = new RecordedBatches( 5, static fn () => $clock->advance( 10 ) );
- $scheduler = 'action-scheduler' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler();
+ $scheduler = 'action-scheduler' === $kind ? new Scheduler( $this->identity ) : $this->make_wp_cron_scheduler();
$requests = 0;
while ( 1 > MigrationRunner::get_revision( $this->option ) && $requests < 5 ) {
@@ -992,7 +986,7 @@ public function test_a_batched_step_finishes_through_real_scheduler_dispatch( st
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' ) ) );
+ $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( $this->identity ) ) );
self::run_due_actions( "$this->hook_root/migration/run" );
// A queue run that pauses between actions claims again later, as wp action-scheduler run --pause does.
@@ -1002,7 +996,7 @@ public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_prog
self::assertSame( 0, $this->count_pending_runs() );
remove_all_actions( "$this->hook_root/migration/run" );
- $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) );
+ $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( $this->identity ) ) );
self::run_due_actions( "$this->hook_root/migration/run" );
self::assertSame( 2, $batches->processed );
@@ -1182,14 +1176,14 @@ public function test_run_now_refuses_an_invalid_nonce_and_users_who_cannot_activ
}
protected function make_runner( array $steps, ?PsrClockInterface $clock = null, ?PsrLoggerInterface $logger = null, ?SchedulerInterface $scheduler = null ): MigrationRunner {
- return new MigrationRunner( self::PLUGIN_FILE, $this->slug, $this->option, $steps, $logger ?? $this->logger, $scheduler ?? $this->make_wp_cron_scheduler(), 20, $clock );
+ return new MigrationRunner( $this->identity, $steps, $logger ?? $this->logger, $scheduler ?? $this->make_wp_cron_scheduler(), 20, $clock );
}
// Action Scheduler below its floor leaves the writes to WP-Cron.
protected function make_wp_cron_scheduler(): Scheduler {
$this->report_action_scheduler_versions( array( '3.9.0' ) );
- return new Scheduler( 'acme' );
+ return new Scheduler( $this->identity );
}
protected function step( int|string $name ): \Closure {
diff --git a/tests/Integration/Core/Permission/CapabilityGrantsTest.php b/tests/Integration/Core/Permission/CapabilityGrantsTest.php
index e878461..06c099d 100644
--- a/tests/Integration/Core/Permission/CapabilityGrantsTest.php
+++ b/tests/Integration/Core/Permission/CapabilityGrantsTest.php
@@ -53,23 +53,20 @@ public static function invalid_constructions(): array {
$cap = array( 'acme_cap' );
return array(
- 'empty-option' => array( '', array( 'acme_role' => $cap ) ),
- 'padded-option' => array( ' acme_grants', array( 'acme_role' => $cap ) ),
- 'overlong-option' => array( \str_repeat( 'a', 192 ), array( 'acme_role' => $cap ) ),
- 'empty-role' => array( 'acme_grants', array( '' => $cap ) ),
- 'numeric-role' => array( 'acme_grants', array( 7 => $cap ) ),
- 'caps-not-an-array' => array( 'acme_grants', array( 'acme_role' => 'acme_cap' ) ),
- 'caps-not-a-list' => array( 'acme_grants', array( 'acme_role' => array( 'a' => 'acme_cap' ) ) ),
- 'empty-cap' => array( 'acme_grants', array( 'acme_role' => array( '' ) ) ),
- 'non-string-cap' => array( 'acme_grants', array( 'acme_role' => array( 1 ) ) ),
+ 'empty-role' => array( array( '' => $cap ) ),
+ 'numeric-role' => array( array( 7 => $cap ) ),
+ 'caps-not-an-array' => array( array( 'acme_role' => 'acme_cap' ) ),
+ 'caps-not-a-list' => array( array( 'acme_role' => array( 'a' => 'acme_cap' ) ) ),
+ 'empty-cap' => array( array( 'acme_role' => array( '' ) ) ),
+ 'non-string-cap' => array( array( 'acme_role' => array( 1 ) ) ),
);
}
#[DataProvider( 'invalid_constructions' )]
- public function test_an_invalid_option_or_grants_map_throws_on_construction( string $option, array $grants ): void {
+ public function test_an_invalid_grants_map_throws_on_construction( array $grants ): void {
$this->expectException( InvalidArgumentException::class );
- new CapabilityGrants( $option, $grants, $this->logger );
+ new CapabilityGrants( $this->identity, $grants, $this->logger );
}
public function test_construction_reads_writes_and_hooks_nothing(): void {
@@ -263,13 +260,13 @@ public function test_deleting_an_undeclared_role_or_writing_another_option_leave
$grants = $this->grants( array( $editor => array( $this->cap( 'a' ) ) ) );
$grants->register_hooks();
$grants->run();
- $row = get_option( "{$this->prefix}_grants" );
+ $row = get_option( "{$this->prefix}_capabilities" );
remove_role( $other );
add_option( "{$this->prefix}_unrelated", array( $editor => array() ) );
update_option( "{$this->prefix}_unrelated", array() );
- self::assertSame( $row, get_option( "{$this->prefix}_grants" ) );
+ self::assertSame( $row, get_option( "{$this->prefix}_capabilities" ) );
}
public function test_a_changed_declaration_grants_the_added_pairs_and_revokes_the_dropped_ones_in_one_run(): void {
@@ -345,7 +342,7 @@ public function test_a_warning_whose_logger_throws_reaches_the_error_log_without
wp_delete_file( $log_file );
}
- self::assertStringContainsString( "[{$this->prefix}_grants.warning] The roles row did not keep the capability changes {changes}, so the next request makes them again. {\"changes\":\"grant {$this->cap( 'a' )} to $editor\"}", $log );
+ self::assertStringContainsString( "[{$this->slug}.warning] The roles row did not keep the capability changes {changes}, so the next request makes them again. {\"changes\":\"grant {$this->cap( 'a' )} to $editor\"}", $log );
self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log );
}
@@ -357,7 +354,7 @@ public function test_while_a_run_grants_the_ledger_row_lists_the_pairs_as_pendin
$this->before_query(
wp_roles()->role_key,
function () use ( $wpdb, &$pending ): void {
- $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "{$this->prefix}_grants" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the options cache.
+ $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "{$this->prefix}_capabilities" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the options cache.
$pending = maybe_unserialize( (string) $raw )['pending'] ?? null;
}
);
@@ -365,20 +362,20 @@ function () use ( $wpdb, &$pending ): void {
$this->grants( array( $editor => array( $this->cap( 'a' ) ) ) )->run();
self::assertSame( array( $editor => array( $this->cap( 'a' ) ) ), $pending );
- self::assertSame( array(), get_option( "{$this->prefix}_grants" )['pending'] );
+ self::assertSame( array(), get_option( "{$this->prefix}_capabilities" )['pending'] );
}
public function test_a_pending_record_the_ledger_row_does_not_keep_is_logged_and_grants_nothing_until_the_next_request(): void {
$editor = $this->add_role( 'editor' );
$declared = array( $editor => array( $this->cap( 'a' ) ) );
- add_filter( "pre_update_option_{$this->prefix}_grants", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 );
+ add_filter( "pre_update_option_{$this->prefix}_capabilities", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 );
$this->grants( $declared )->run();
self::assertSame( array(), $this->stored_caps( $editor ) );
self::assertSame( array( array( 'warning', 'The ledger row did not keep the pending grants, so the next request makes them.', array() ) ), $this->logger->records );
- remove_all_filters( "pre_update_option_{$this->prefix}_grants" );
+ remove_all_filters( "pre_update_option_{$this->prefix}_capabilities" );
$this->next_request();
$this->grants( $declared )->run();
@@ -395,7 +392,7 @@ public function test_a_run_interrupted_after_its_pending_write_converges_on_the_
)
);
update_option(
- "{$this->prefix}_grants",
+ "{$this->prefix}_capabilities",
array(
'v' => 1,
'fingerprint' => '',
@@ -422,7 +419,7 @@ public function test_a_run_interrupted_after_its_pending_write_converges_on_the_
public function test_a_pending_grant_that_the_declaration_has_since_dropped_is_revoked(): void {
$editor = $this->add_role( 'editor', array( $this->cap( 'a' ) => true ) );
update_option(
- "{$this->prefix}_grants",
+ "{$this->prefix}_capabilities",
array(
'v' => 1,
'fingerprint' => '',
@@ -481,7 +478,7 @@ public function test_a_missing_or_malformed_row_reads_as_an_empty_ledger( string
),
),
};
- update_option( "{$this->prefix}_grants", $row, true );
+ update_option( "{$this->prefix}_capabilities", $row, true );
$this->grants( $grants )->run();
@@ -512,11 +509,11 @@ public function test_a_run_and_a_role_deletion_change_only_their_own_sites_roles
self::assertEquals( array( $editor => array( $this->cap( 'a' ) ) ), $ledger );
self::assertSame( array(), $forgotten );
self::assertArrayNotHasKey( $editor, $this->stored_roles() );
- self::assertFalse( get_option( "{$this->prefix}_grants" ) );
+ self::assertFalse( get_option( "{$this->prefix}_capabilities" ) );
}
protected function grants( array $grants, ?PsrLoggerInterface $logger = null ): CapabilityGrants {
- return new CapabilityGrants( "{$this->prefix}_grants", $grants, $logger ?? $this->logger );
+ return new CapabilityGrants( $this->identity, $grants, $logger ?? $this->logger );
}
protected function role( string $name ): string {
@@ -563,7 +560,7 @@ static function ( array $caps ): array {
\sort( $caps );
return $caps;
},
- get_option( "{$this->prefix}_grants" )['grants'] ?? array()
+ get_option( "{$this->prefix}_capabilities" )['grants'] ?? array()
);
}
diff --git a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php
index a7e7c1d..18bc600 100644
--- a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php
+++ b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php
@@ -54,7 +54,7 @@ function ( \Throwable $failure ): void {
public function test_a_failing_composition_is_rethrown_after_the_failure_action(): void {
self::assertSame( 'local', wp_get_environment_type() );
$failure = new \RuntimeException( 'composition failed' );
- $boot = new PluginBoot( __DIR__ . '/../../Fixtures/Core/acme-boot.php', $this->slug, $this->prefix, new PsrNullLogger() );
+ $boot = new PluginBoot( $this->identity, new PsrNullLogger() );
$caught = null;
try {
diff --git a/tests/Integration/Core/PluginBootTest.php b/tests/Integration/Core/PluginBootTest.php
index a52010d..314185a 100644
--- a/tests/Integration/Core/PluginBootTest.php
+++ b/tests/Integration/Core/PluginBootTest.php
@@ -140,7 +140,7 @@ public static function logging_failures(): array {
#[DataProvider( 'logging_failures' )]
public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_and_is_announced( \Throwable $logging_failure ): void {
$failure = new \RuntimeException( 'composition failed' );
- $boot = new PluginBoot( self::PLUGIN_FILE, $this->slug, $this->prefix, new ThrowingLogger( $logging_failure ) );
+ $boot = new PluginBoot( $this->identity, new ThrowingLogger( $logging_failure ) );
$log_file = (string) \tempnam( \sys_get_temp_dir(), 'acme-log-' );
$previous = \ini_set( 'error_log', $log_file );
@@ -155,13 +155,13 @@ public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_a
self::assertSame( 'failed', $boot->status );
self::assertSame( array( 'failed' ), $this->fired );
self::assertSame( array( $failure ), $this->failures );
- self::assertStringContainsString( "[$this->prefix.error] Boot failed; the plugin did not finish starting: {message} {\"message\":\"composition failed\"}", $log );
+ self::assertStringContainsString( "[$this->slug.error] Boot failed; the plugin did not finish starting: {message} {\"message\":\"composition failed\"}", $log );
self::assertStringContainsString( 'RuntimeException: composition failed in ' . __FILE__ . ':' . $failure->getLine(), $log );
self::assertStringContainsString( $logging_failure::class . ': ' . $logging_failure->getMessage(), $log );
self::assertStringContainsString( self::NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-boot-admin', 'administrator' ) ) );
}
protected function make_boot(): PluginBoot {
- return new PluginBoot( self::PLUGIN_FILE, $this->slug, $this->prefix, $this->logger );
+ return new PluginBoot( $this->identity, $this->logger );
}
}
diff --git a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php
index 6f327b0..2b1fe71 100644
--- a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php
+++ b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Scheduling;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook;
use DeepWebSolutions\Framework\Core\Scheduling\Scheduler;
use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait;
@@ -111,7 +112,7 @@ public function test_a_switch_to_the_site_that_loaded_the_plugin_still_schedules
// The network carries no WooCommerce, so the default backends resolve to WP-Cron alone.
protected function make_scheduler(): Scheduler {
- $scheduler = new Scheduler( 'acme' );
+ $scheduler = new Scheduler( new PluginIdentity( __FILE__, 'acme', 'acme' ) );
$scheduler->register_hooks();
return $scheduler;
diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php
index 7caf58a..6c7bd21 100644
--- a/tests/Integration/Core/Scheduling/SchedulerTest.php
+++ b/tests/Integration/Core/Scheduling/SchedulerTest.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Scheduling;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Scheduling\Backends\ActionSchedulerBackend;
use DeepWebSolutions\Framework\Core\Scheduling\Backends\WpCronBackend;
use DeepWebSolutions\Framework\Core\Scheduling\Recurrence;
@@ -73,43 +74,10 @@ 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( '' );
+ new Scheduler( new PluginIdentity( __FILE__, 'acme', self::GROUP ) )->unschedule_hook( '' );
}
public static function schedulers(): array {
@@ -426,7 +394,7 @@ function () use ( $scheduler, $hook ): void {
public function test_a_recurring_wp_cron_write_needs_no_earlier_hook_registration(): void {
$this->report_action_scheduler_versions( array( '3.9.0' ) );
- $scheduler = new Scheduler( self::GROUP );
+ $scheduler = new Scheduler( new PluginIdentity( __FILE__, 'acme', self::GROUP ) );
self::assertTrue( $scheduler->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3900 ) ) );
@@ -832,7 +800,7 @@ protected static function assert_wp_error( mixed $actual, string $code ): void {
// Action Scheduler below its floor leaves the writes to WP-Cron. A scheduler reads the version at each call, so the kind holds until the next call here.
protected function make_scheduler( string $kind ): Scheduler {
'wp-cron' === $kind ? $this->report_action_scheduler_versions( array( '3.9.0' ) ) : $this->restore_action_scheduler_versions();
- $scheduler = new Scheduler( self::GROUP );
+ $scheduler = new Scheduler( new PluginIdentity( __FILE__, 'acme', self::GROUP ) );
$scheduler->register_hooks();
return $scheduler;
diff --git a/tests/Integration/Core/Storage/LockTest.php b/tests/Integration/Core/Storage/LockTest.php
index d5ca1ff..7dc6e51 100644
--- a/tests/Integration/Core/Storage/LockTest.php
+++ b/tests/Integration/Core/Storage/LockTest.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Storage;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Storage\Lock;
use DeepWebSolutions\Framework\Core\Storage\LockClaim;
use DeepWebSolutions\Framework\Core\Storage\OptionRows;
@@ -53,7 +54,7 @@ public function test_a_hold_past_its_ttl_is_taken_over(): void {
}
public function test_expiry_is_judged_by_the_ttl_the_row_holds(): void {
- new Lock( "{$this->prefix}_lock", 600, $this->clock )->acquire( 'first' );
+ new Lock( $this->identity, 'lock', 600, $this->clock )->acquire( 'first' );
$this->clock->advance( 61 );
self::assertSame( LockClaim::Busy, $this->make_lock()->acquire( 'second' ) );
@@ -219,7 +220,7 @@ public function test_a_release_that_races_a_takeover_leaves_the_new_hold(): void
public function test_without_a_clock_the_lock_reads_the_system_time(): void {
$before = \time();
- new Lock( "{$this->prefix}_lock", 60 )->acquire( 'first' );
+ new Lock( $this->identity, 'lock', 60 )->acquire( 'first' );
$time = $this->stored()['time'] ?? 0;
self::assertGreaterThanOrEqual( $before, $time );
@@ -228,18 +229,18 @@ public function test_without_a_clock_the_lock_reads_the_system_time(): void {
public static function invalid_calls(): array {
return array(
- 'zero-ttl' => array( static fn (): mixed => new Lock( 'acme_lock', 0 ) ),
- 'negative-ttl' => array( static fn (): mixed => new Lock( 'acme_lock', -60 ) ),
- 'empty-name' => array( static fn (): mixed => new Lock( '', 60 ) ),
- 'empty-owner' => array( static fn (): mixed => new Lock( 'acme_lock', 60 )->acquire( '' ) ),
+ 'zero-ttl' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, 'lock', 0 ) ),
+ 'negative-ttl' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, 'lock', -60 ) ),
+ 'long-name' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, \str_repeat( 'a', 191 - \strlen( $identity->prefix ) ), 60 ) ),
+ 'empty-owner' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, 'lock', 60 )->acquire( '' ) ),
);
}
#[DataProvider( 'invalid_calls' )]
- public function test_a_ttl_below_1_an_invalid_name_or_an_empty_owner_is_rejected( \Closure $call ): void {
+ public function test_a_ttl_below_1_a_name_too_long_for_an_option_or_an_empty_owner_is_rejected( \Closure $call ): void {
$this->expectException( InvalidArgumentException::class );
- $call();
+ $call( $this->identity );
}
public static function failing_operations(): array {
@@ -262,7 +263,7 @@ public function test_a_storage_failure_throws( string $needle, \Closure $operati
}
protected function make_lock(): Lock {
- return new Lock( "{$this->prefix}_lock", 60, $this->clock );
+ return new Lock( $this->identity, 'lock', 60, $this->clock );
}
protected function stored(): mixed {
diff --git a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php
index 210bfba..be3f4e7 100644
--- a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php
+++ b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Core\Uninstall;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait;
use PHPUnit\Framework\Attributes\After;
@@ -16,6 +17,10 @@ trait UninstallFixturesTrait {
protected array $id_reads = array();
+ protected function uninstalled_plugin(): PluginIdentity {
+ return new PluginIdentity( self::PLUGIN_FILE, $this->slug, $this->prefix );
+ }
+
protected function footprint(): array {
$p = $this->prefix;
diff --git a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
index 07ace5d..19b5952 100644
--- a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
+++ b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
@@ -60,7 +60,7 @@ public function test_a_subsite_only_plugin_deleted_from_the_network_admin_cleans
$this->seed_site( $this->main_site, null );
$this->seed_site( $this->other_site, 'yes' );
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() );
self::assertSame( array( true, true, false, false ), $this->site_state( $this->main_site ) );
self::assertSame( array( false, false, false, false ), $this->site_state( $this->other_site ) );
@@ -103,7 +103,7 @@ function () use ( $p ): void {
try {
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'option_prefixes' => array( "{$p}_cache_" ),
'meta' => array( 'item' => array( "{$p}_flag" ) ),
@@ -138,7 +138,7 @@ public function test_the_network_pass_runs_only_on_the_consent_constant( bool $c
$this->seed_site( $this->main_site, 'yes' );
$this->seed_site( $this->other_site, 'yes' );
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( $constant ) );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( $constant ) );
// The constant consents for every site of the network, which may hold more sites than the test created.
$consenting = $constant ? get_sites(
@@ -165,7 +165,7 @@ public function test_the_network_pass_reads_a_global_user_meta_key_in_pages_and_
}
$this->record_id_reads();
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( true ) );
self::assertFalse( $this->user_meta_exists( "{$this->prefix}_global_state" ) );
$this->assert_id_reads_paged();
@@ -183,7 +183,7 @@ public function test_the_network_pass_keeps_a_network_option_that_differs_only_i
)
);
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( true ) );
self::assertFalse( $this->network_option_exists( "{$this->prefix}_network_settings" ) );
self::assertTrue( $this->network_option_exists( $twin ) );
@@ -194,7 +194,7 @@ public function test_the_network_pass_clears_a_deleted_network_options_cached_va
// The network cleanup runs just before the deletes, so it caches the value they must clear.
$args['cleanup'] = fn ( array $context ): mixed => get_network_option( null, "{$this->prefix}_network_settings" );
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $args );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $args );
self::assertFalse( get_network_option( null, "{$this->prefix}_network_settings" ) );
}
@@ -214,7 +214,7 @@ public function test_a_throw_on_one_site_leaves_the_other_sites_swept_and_is_thr
$failure = null;
try {
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() );
} catch ( RuntimeException $exception ) {
$failure = $exception;
} finally {
@@ -236,7 +236,7 @@ public function test_a_web_request_on_a_large_network_refuses_before_any_change_
$failure = null;
try {
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( true ) );
} catch ( RuntimeException $exception ) {
$failure = $exception;
}
@@ -258,7 +258,7 @@ public function test_wp_cli_sweeps_a_large_network(): void {
$this->seed_site( $this->main_site, 'yes' );
$this->seed_site( $this->other_site, 'yes' );
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() );
self::assertSame( array( false, false, false, false ), $this->site_state( $this->main_site ) );
self::assertSame( array( false, false, false, false ), $this->site_state( $this->other_site ) );
@@ -273,7 +273,7 @@ public function test_memory_stays_flat_across_a_sixty_site_sweep(): void {
\gc_collect_cycles();
$before = \memory_get_usage();
- Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() );
\gc_collect_cycles();
$growth = \memory_get_usage() - $before;
diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php
index 670bb4e..9d65733 100644
--- a/tests/Integration/Core/Uninstall/UninstallerTest.php
+++ b/tests/Integration/Core/Uninstall/UninstallerTest.php
@@ -84,7 +84,7 @@ public function test_a_run_without_consent_keeps_the_footprint_and_runs_no_clean
$args = $consent( $this->prefix ) + $this->recording_cleanup();
$consent_stored = $this->option_exists( "{$this->prefix}_remove_data" );
- Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $args );
$this->assert_footprint_kept();
self::assertSame( array(), $this->calls );
@@ -96,7 +96,7 @@ public function test_a_consenting_run_without_a_current_user_deletes_exactly_the
wp_set_current_user( 0 ); // WP-CLI uninstalls a plugin with no current user.
$this->seed_footprint();
- Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $consent( $this->prefix ) );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $consent( $this->prefix ) );
$this->assert_footprint_deleted();
$this->assert_foreign_rows_kept();
@@ -110,7 +110,7 @@ public function test_a_key_on_more_rows_than_one_read_takes_is_read_in_pages_and
}
$this->record_id_reads();
- Uninstaller::run( self::PLUGIN_FILE, array( 'meta' => array( 'post' => array( "{$this->prefix}_flag" ) ) ), self::stored_consent( 'yes' )( $this->prefix ) );
+ Uninstaller::run( $this->uninstalled_plugin(), 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();
@@ -125,7 +125,7 @@ public function test_a_post_of_an_unregistered_type_is_deleted_with_its_term_rel
$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 ) );
+ Uninstaller::run( $this->uninstalled_plugin(), 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.
@@ -142,7 +142,7 @@ public function test_a_deleted_posts_id_keeps_its_relationships_in_a_taxonomy_of
$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 ) );
+ Uninstaller::run( $this->uninstalled_plugin(), 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.
@@ -165,7 +165,7 @@ public function test_a_post_whose_delete_wordpress_refuses_keeps_its_term_relati
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 ) );
+ Uninstaller::run( $this->uninstalled_plugin(), 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 ) );
@@ -183,7 +183,7 @@ public function test_the_cleanup_runs_first_while_the_footprint_and_the_consent_
$seen = array();
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'options' => array( $consent_option ),
'option_prefixes' => array( "{$this->prefix}_cache_" ),
@@ -207,7 +207,7 @@ public function test_the_cleanup_runs_once_with_the_site_context(): void {
$log = $this->capture_error_log(
fn () => Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array( 'options' => array( "{$this->prefix}_settings" ) ),
array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) + $this->recording_cleanup()
)
@@ -227,7 +227,7 @@ public function test_a_kind_keeps_its_non_empty_strings_and_skips_every_other_en
update_option( "{$this->prefix}_remove_data", 'yes' );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'options' => array( '', '0', 7, null, array( "{$this->prefix}_cache_a" ), "{$this->prefix}_settings" ),
'option_prefixes' => array( '', '0' ),
@@ -247,7 +247,7 @@ public function test_a_meta_kind_skips_the_names_of_unknown_meta_types_and_clean
update_option( "{$this->prefix}_remove_data", 'yes' );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'meta' => array(
'user' => array( "{$this->prefix}_global_state" ),
@@ -279,7 +279,7 @@ public function test_a_job_wordpress_refuses_to_unschedule_is_logged_without_hol
try {
$log = $this->capture_error_log(
fn () => Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ) + $this->footprint(),
array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) )
)
@@ -301,7 +301,7 @@ public function test_cron_hooks_are_unscheduled_with_every_argument_variant_what
wp_schedule_single_event( \time() + 3600, "{$this->prefix}_cron", array( 2 ) );
wp_schedule_single_event( \time() + 3600, "{$this->prefix}_foreign_cron" );
- Uninstaller::run( self::PLUGIN_FILE, array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) );
+ Uninstaller::run( $this->uninstalled_plugin(), array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) );
self::assertFalse( wp_next_scheduled( "{$this->prefix}_cron", array( 1 ) ) );
self::assertFalse( wp_next_scheduled( "{$this->prefix}_cron", array( 2 ) ) );
@@ -317,7 +317,7 @@ public function test_action_scheduler_actions_are_unscheduled_by_hook_and_by_gro
as_schedule_single_action( \time() + 3600, "{$this->prefix}_foreign_as" );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'as_hooks' => array( "{$this->prefix}_as" ),
'as_groups' => array( "$this->prefix-group" ),
@@ -336,7 +336,7 @@ public function test_an_action_scheduler_hook_or_group_named_0_cancels_no_other_
as_schedule_single_action( \time() + 3600, "{$this->prefix}_grouped", array(), "$this->prefix-group" );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'as_hooks' => array( '0' ),
'as_groups' => array( '0' ),
@@ -359,7 +359,7 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t
$failure = null;
try {
- Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $args );
} catch ( RuntimeException $exception ) {
$failure = $exception;
}
@@ -373,7 +373,7 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t
remove_all_filters( 'query' );
$this->restore_query_errors();
- Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $args );
self::assertFalse( $kept( $this ) );
self::assertFalse( $this->option_exists( $this->prefix . $consent ) );
@@ -389,7 +389,7 @@ public function test_a_delete_wordpress_refuses_is_thrown_after_the_rest_and_a_r
$failure = null;
try {
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ) + $this->footprint(),
array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) )
);
@@ -405,7 +405,7 @@ public function test_a_delete_wordpress_refuses_is_thrown_after_the_rest_and_a_r
self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ) + $this->footprint(),
array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) )
);
@@ -422,7 +422,7 @@ public function test_a_failing_cleanup_keeps_that_sites_footprint_and_consent_ro
$failure = null;
try {
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'cron_hooks' => array( "{$this->prefix}_cron" ),
'options' => array( "{$this->prefix}_settings" ),
@@ -453,7 +453,7 @@ public function test_a_throw_from_another_plugins_delete_hook_ends_the_sites_pas
$failure = null;
try {
- Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) );
} catch ( RuntimeException $exception ) {
$failure = $exception;
} finally {
@@ -466,7 +466,7 @@ public function test_a_throw_from_another_plugins_delete_hook_ends_the_sites_pas
self::assertTrue( $this->table_exists( "{$this->prefix}_log" ) );
self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) );
- Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) );
+ Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) );
$this->assert_footprint_deleted();
self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) );
@@ -483,7 +483,7 @@ public function test_an_action_scheduler_cancel_that_throws_is_logged_without_ho
try {
$log = $this->capture_error_log(
fn () => Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array( 'as_hooks' => array( "{$this->prefix}_as" ) ) + $this->footprint(),
array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) )
)
diff --git a/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php b/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php
index f9686e0..63f8160 100644
--- a/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php
+++ b/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php
@@ -21,7 +21,7 @@ public function test_a_consenting_run_without_action_scheduler_completes(): void
wp_schedule_single_event( \time() + 3600, "{$this->prefix}_cron" );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
array(
'cron_hooks' => array( "{$this->prefix}_cron" ),
'as_hooks' => array( "{$this->prefix}_as" ),
diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
index 05df76e..25bade3 100644
--- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php
+++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
@@ -81,19 +81,19 @@ public static function relocated_option_pages(): array {
public function test_an_invalid_id_is_rejected( string $id ): void {
$this->expectException( InvalidArgumentException::class );
- new OptionsPage( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ) );
+ new OptionsPage( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ) );
}
#[DataProvider( 'v1_helper_ids' )]
public function test_every_v1_key_is_a_valid_id( string $id ): void {
$this->expectNotToPerformAssertions();
- new OptionsPage( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ) );
+ new OptionsPage( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ) );
}
public function test_the_definition_is_evaluated_neither_at_construction_nor_at_registration(): void {
$definition = new RecordedDefinition( static fn (): array => array() );
- $page = new OptionsPage( $this->slug, $this->id, $definition );
+ $page = new OptionsPage( $this->identity, $this->id, $definition );
$page->register_hooks();
@@ -110,7 +110,7 @@ public function test_the_definition_receives_the_pages_id_once(): void {
'tabs' => array(),
)
);
- $page = new OptionsPage( $this->slug, $this->id, $definition );
+ $page = new OptionsPage( $this->identity, $this->id, $definition );
$page->get_capability();
$page->get_capability();
diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
index 9bb3251..09e05c0 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
@@ -4,6 +4,7 @@
use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox;
use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
@@ -70,19 +71,19 @@ public static function heartbeat_refreshes(): array {
public function test_an_invalid_form_id_is_rejected( string $id ): void {
$this->expectException( InvalidArgumentException::class );
- new ObjectForm( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) );
+ new ObjectForm( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) );
}
#[DataProvider( 'v1_helper_ids' )]
public function test_every_v1_key_is_a_valid_form_id( string $id ): void {
- $form = new ObjectForm( $this->slug, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) );
+ $form = new ObjectForm( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) );
self::assertSame( $id, $form->get_id() );
}
public function test_the_definition_is_evaluated_only_when_a_screen_needs_it(): void {
$definition = new RecordedDefinition( static fn (): array => array() );
- $form = new ObjectForm( $this->slug, $this->id, $definition, new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) );
+ $form = new ObjectForm( $this->identity, $this->id, $definition, new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) );
$form->register_hooks();
$post_id = $this->make_post();
$spec = $this->spec( array( $this->field( 'text', 'name' ) ) );
@@ -531,7 +532,7 @@ public function test_bulk_edit_stores_nothing(): void {
public function test_a_save_without_the_forms_nonce_stores_nothing(): void {
$post_id = $this->make_post( array( "_{$this->prefix}_name" => 'kept' ) );
$form = new ObjectForm(
- $this->slug,
+ $this->identity,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -541,7 +542,7 @@ public function test_a_save_without_the_forms_nonce_stores_nothing(): void {
)
),
new PostMetaBox( array( 'post' ) ),
- new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
@@ -676,7 +677,7 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th
$saved = $this->block_editor_save( $spec, $post_id, array( $this->control_name( 'note' ) => 'too long' ) );
$redirected = $this->follow( $spec, $saved );
wp_set_current_user( $this->admin );
- $delivered = new AdminNotices( $this->slug, $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver();
+ $delivered = new AdminNotices( new PluginIdentity( $this->identity->file, $this->slug, $this->id ), new ErrorLogLogger( $this->identity ) )->rest_deliver();
$list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec );
self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) );
diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
index 02a576d..286d217 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
@@ -37,7 +37,7 @@ protected function setUp(): void {
public function test_the_host_registers_on_each_of_its_taxonomies(): void {
$host = new TermForm( array( 'category', 'post_tag' ) );
- new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks();
foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
self::assertSame( 10, has_action( "{$taxonomy}_edit_form", array( $host, 'render_section' ) ) );
@@ -139,7 +139,7 @@ public function test_an_update_in_code_stores_nothing(): void {
$term_id = $this->make_term( 'category' );
add_term_meta( $term_id, "_{$this->prefix}_name", 'kept' );
$form = new ObjectForm(
- $this->slug,
+ $this->identity,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -149,7 +149,7 @@ public function test_an_update_in_code_stores_nothing(): void {
)
),
new TermForm( array( 'category' ) ),
- new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
index 551a3f4..7b40434 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
@@ -51,7 +51,7 @@ public static function multisite_profiles(): array {
public function test_the_host_registers_on_both_profile_screens(): void {
$host = new UserProfile();
- new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks();
self::assertSame( 10, has_action( 'show_user_profile', array( $host, 'render_section' ) ) );
self::assertSame( 10, has_action( 'edit_user_profile', array( $host, 'render_section' ) ) );
@@ -192,7 +192,7 @@ public function test_a_user_editing_themself_without_the_forms_capability_gets_n
public function test_an_update_in_code_stores_nothing(): void {
add_user_meta( $this->subscriber, "_{$this->prefix}_name", 'kept' );
$form = new ObjectForm(
- $this->slug,
+ $this->identity,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -202,7 +202,7 @@ public function test_an_update_in_code_stores_nothing(): void {
)
),
new UserProfile(),
- new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
index 9d38b81..efe5a1b 100644
--- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php
+++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
@@ -157,7 +157,7 @@ public function test_a_save_without_the_forms_nonce_stores_nothing_and_evaluates
public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user_who_saved(): void {
wp_set_current_user( $this->author_id );
$this->post_form( $this->post_id );
- $notices = new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
+ $notices = new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) );
$this->rejecting_form( $notices )->save( $this->post_id );
@@ -171,7 +171,7 @@ public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user
public function test_a_second_rejection_of_a_field_replaces_the_first(): void {
wp_set_current_user( $this->author_id );
- $notices = new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) );
+ $notices = new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) );
$this->post_form( $this->post_id );
$this->rejecting_form( $notices )->save( $this->post_id );
@@ -192,7 +192,7 @@ public function test_a_rejection_whose_notice_cannot_be_stored_is_logged_and_kee
$previous = \ini_set( 'error_log', $log_file );
try {
- $this->rejecting_form( new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->save( $this->post_id );
+ $this->rejecting_form( new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->save( $this->post_id );
$log = (string) \file_get_contents( $log_file );
} finally {
\ini_set( 'error_log', (string) $previous );
@@ -200,7 +200,7 @@ public function test_a_rejection_whose_notice_cannot_be_stored_is_logged_and_kee
}
self::assertSame( 'Saved.', get_post_meta( $this->post_id, "_{$this->prefix}_name", true ) );
- self::assertStringContainsString( '[acme.error] Could not add a user notice: The user meta is unavailable.', $log );
+ self::assertStringContainsString( "[$this->slug.error] Could not add a user notice: The user meta is unavailable.", $log );
}
public function test_a_host_serves_one_form(): void {
@@ -209,7 +209,7 @@ public function test_a_host_serves_one_form(): void {
$this->expectException( LogicException::class );
- new ObjectForm( $this->slug, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->identity, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks();
}
public function test_a_host_the_consumer_writes_serves_one_form_too(): void {
@@ -236,7 +236,7 @@ public function render_rows( string $title, array $rows ): void {}
$this->expectException( LogicException::class );
- new ObjectForm( $this->slug, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->identity, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks();
}
protected function form( ObjectFormHostInterface $host, string $capability = 'edit_posts' ): ObjectForm {
@@ -254,11 +254,11 @@ protected function form( ObjectFormHostInterface $host, string $capability = 'ed
);
return new ObjectForm(
- $this->slug,
+ $this->identity,
"{$this->prefix}_details",
$this->definition,
$host,
- new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) )
);
}
@@ -266,7 +266,7 @@ protected function rejecting_form( AdminNotices $notices, string $title = 'Note
$_POST[ "{$this->prefix}_details" ][ "_{$this->prefix}_note" ] = 'Too long.';
return new ObjectForm(
- $this->slug,
+ $this->identity,
"{$this->prefix}_details",
new RecordedDefinition(
fn (): array => array(
diff --git a/tests/Integration/Support/IsolatesStorageTrait.php b/tests/Integration/Support/IsolatesStorageTrait.php
index a1e1b2c..0beb9e2 100644
--- a/tests/Integration/Support/IsolatesStorageTrait.php
+++ b/tests/Integration/Support/IsolatesStorageTrait.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Support;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use PHPUnit\Framework\Attributes\After;
use PHPUnit\Framework\Attributes\Before;
@@ -12,6 +13,8 @@ trait IsolatesStorageTrait {
protected string $hook_root;
+ protected PluginIdentity $identity;
+
// The slug differs from the prefix, so a row tells a hook or REST name from a storage key.
#[Before]
protected function use_a_fresh_prefix(): void {
@@ -19,6 +22,7 @@ protected function use_a_fresh_prefix(): void {
$this->prefix = "acme_$suffix";
$this->slug = "acme-plugin-$suffix";
$this->hook_root = "acme_plugin_$suffix";
+ $this->identity = new PluginIdentity( \dirname( __DIR__, 2 ) . '/Fixtures/Core/acme-boot.php', $this->slug, $this->prefix );
}
#[After]
diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
index e410eb3..7440c0d 100644
--- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
+++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
@@ -55,7 +55,7 @@ public static function silent_and_worded_rejections(): array {
public function test_the_box_is_added_on_add_meta_boxes(): void {
$host = new OrderMetaBox();
- new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks();
self::assertSame( 10, has_action( 'add_meta_boxes', array( $host, 'add_meta_box' ) ) );
}
@@ -202,7 +202,7 @@ public function test_per_object_fields_receive_the_order_and_contributed_fields_
public function test_the_box_saves_before_woocommerces_order_data(): void {
$host = new OrderMetaBox();
- new ObjectForm( $this->slug, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks();
+ new ObjectForm( $this->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks();
self::assertSame( 35, has_action( 'woocommerce_process_shop_order_meta', array( $host, 'save_meta_box' ) ) );
}
@@ -328,7 +328,7 @@ public function test_a_user_without_the_forms_capability_stores_nothing(): void
public function test_an_order_saved_in_code_stores_nothing(): void {
$order = $this->make_order( array( "_{$this->prefix}_name" => 'kept' ) );
$form = new ObjectForm(
- $this->slug,
+ $this->identity,
$this->id,
new RecordedDefinition(
fn (): array => array(
@@ -338,7 +338,7 @@ public function test_an_order_saved_in_code_stores_nothing(): void {
)
),
new OrderMetaBox(),
- new AdminNotices( $this->slug, $this->prefix, new ErrorLogLogger( 'acme' ) )
+ new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) )
);
$form->register_hooks();
wp_set_current_user( $this->admin );
diff --git a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php
index 63d373b..c8547c6 100644
--- a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php
+++ b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce\Logging;
use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesLogMessagesTrait;
use DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -100,7 +101,7 @@ public function test_a_threshold_psr_3_does_not_define_is_rejected(): void {
$this->expectException( PsrInvalidArgumentException::class );
$this->expectExceptionMessage( "Log level 'verbose' is not a PSR-3 level." );
- new WooCommerceLogger( 'acme', 'verbose' );
+ new WooCommerceLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ), 'verbose' );
}
public function test_a_level_psr_3_does_not_define_is_rejected(): void {
@@ -110,7 +111,7 @@ public function test_a_level_psr_3_does_not_define_is_rejected(): void {
$this->logger()->log( 'verbose', 'Cache is cold.' );
}
- public function test_every_record_carries_the_constructors_source_even_when_the_context_names_its_own(): void {
+ public function test_every_record_carries_the_plugins_slug_as_its_source_even_when_the_context_names_its_own(): void {
$this->logger( null, 'dws-x' )->error( 'Cache is cold.', array( 'source' => 'other' ) );
self::assertSame( 'dws-x', $this->handler->records[0][2]['source'] );
@@ -229,7 +230,7 @@ public function test_a_record_below_error_never_reaches_the_error_log(): void {
// WooCommerce's own logger applies its logging switch and threshold, unless the test pins the threshold to keep every record.
protected function logger( ?string $threshold = null, string $source = 'acme', bool $keep_everything = true ): WooCommerceLogger {
- return new WooCommerceLogger( $source, $threshold, new \WC_Logger( array( $this->handler ), $keep_everything ? PsrLogLevel::DEBUG : null ) );
+ return new WooCommerceLogger( new PluginIdentity( __FILE__, $source, 'acme' ), $threshold, new \WC_Logger( array( $this->handler ), $keep_everything ? PsrLogLevel::DEBUG : null ) );
}
protected function messages(): array {
diff --git a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php
index a66fe9b..767a995 100644
--- a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php
+++ b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce\Logging;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
@@ -28,7 +29,7 @@ protected function tearDown(): void {
public function test_without_woocommerce_errors_reach_the_error_log_and_lower_levels_go_nowhere(): void {
self::assertFalse( \function_exists( 'wc_get_logger' ) );
- $logger = new WooCommerceLogger( 'acme' );
+ $logger = new WooCommerceLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) );
$logger->warning( 'Cache is cold.' );
$logger->error( 'Payment {id} failed.', array( 'id' => 7 ) );
diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
index a678581..1fe2d99 100644
--- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
+++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
@@ -66,19 +66,19 @@ public static function multi_value_selections(): array {
public function test_an_invalid_tab_id_is_rejected( string $id ): void {
$this->expectException( InvalidArgumentException::class );
- new ProductDataTab( $this->slug, $id, new RecordedDefinition( static fn (): ?array => null ) );
+ new ProductDataTab( $this->identity, $id, new RecordedDefinition( static fn (): ?array => null ) );
}
#[DataProvider( 'v1_helper_ids' )]
public function test_every_v1_key_is_a_valid_tab_id( string $id ): void {
$this->expectNotToPerformAssertions();
- new ProductDataTab( $this->slug, $id, new RecordedDefinition( static fn (): ?array => null ) );
+ new ProductDataTab( $this->identity, $id, new RecordedDefinition( static fn (): ?array => null ) );
}
public function test_the_definition_is_evaluated_only_when_the_edit_screen_needs_it(): void {
$definition = new RecordedDefinition( static fn (): ?array => null );
- $tab = new ProductDataTab( $this->slug, $this->prefix, $definition );
+ $tab = new ProductDataTab( $this->identity, $this->prefix, $definition );
$tab->register_hooks();
@@ -443,7 +443,7 @@ public function test_the_definition_receives_the_tabs_id_and_each_product_once()
'groups' => array(),
)
);
- $tab = new ProductDataTab( $this->slug, $this->prefix, $definition );
+ $tab = new ProductDataTab( $this->identity, $this->prefix, $definition );
$GLOBALS['product_object'] = $product;
try {
@@ -511,7 +511,7 @@ public function test_labels_and_values_are_escaped(): void {
}
public function test_registering_hooks_the_save_onto_the_product_woocommerce_saves(): void {
- $tab = new ProductDataTab( $this->slug, $this->prefix, new RecordedDefinition( static fn (): ?array => null ) );
+ $tab = new ProductDataTab( $this->identity, $this->prefix, new RecordedDefinition( static fn (): ?array => null ) );
$tab->register_hooks();
diff --git a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php
index da0d179..e128cad 100644
--- a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php
+++ b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php
@@ -90,7 +90,7 @@ protected function uninstall( array $footprint ): void {
update_option( "{$this->prefix}_remove_data", 'yes' );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
$footprint,
array(
'consent' => array( 'option' => "{$this->prefix}_remove_data" ),
diff --git a/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php
index b13c46b..689e9a3 100644
--- a/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php
+++ b/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php
@@ -22,7 +22,7 @@ public function test_on_a_site_without_woocommerces_tables_a_consenting_uninstal
update_option( "{$this->prefix}_remove_data", 'yes' );
Uninstaller::run(
- self::PLUGIN_FILE,
+ $this->uninstalled_plugin(),
$this->order_footprint(),
array(
'consent' => array( 'option' => "{$this->prefix}_remove_data" ),
diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php
index 634e1d5..5073225 100644
--- a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php
+++ b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait;
use DeepWebSolutions\Framework\WooCommerce\WooCommerceRequirement;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -26,7 +27,7 @@ public static function met_floors(): array {
public function test_woocommerce_meeting_the_floor_passes_and_nobody_sees_a_notice( ?string $floor ): void {
$headers = \is_null( $floor ) ? array() : array( 'WC requires at least' => 'installed' === $floor ? WC_VERSION : $floor );
- self::assertTrue( WooCommerceRequirement::check( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ) ) );
+ self::assertTrue( WooCommerceRequirement::check( new PluginIdentity( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ), 'acme-shop', 'acme' ) ) );
self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) );
}
@@ -38,7 +39,7 @@ public function test_woocommerce_below_the_floor_fails_and_only_users_who_can_ac
)
);
- self::assertFalse( WooCommerceRequirement::check( $plugin_file ) );
+ self::assertFalse( WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) ) );
$notice = \Dom\HTMLDocument::createFromString( $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' );
self::assertNotNull( $notice );
@@ -53,7 +54,7 @@ public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): voi
'WC requires at least' => '99.1',
)
);
- WooCommerceRequirement::check( $plugin_file );
+ WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) );
$notice = \Dom\HTMLDocument::createFromString( $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' );
@@ -75,7 +76,7 @@ static function ( string $translation ) use ( &$translations ): string {
}
);
- WooCommerceRequirement::check( $plugin_file );
+ WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) );
self::assertSame( 0, $translations );
$this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) );
diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php
index 9c808b6..ed8cce8 100644
--- a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php
+++ b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait;
use DeepWebSolutions\Framework\WooCommerce\WooCommerceRequirement;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -23,7 +24,7 @@ public function test_a_missing_woocommerce_fails_and_only_users_who_can_activate
)
);
- self::assertFalse( WooCommerceRequirement::check( $plugin_file ) );
+ self::assertFalse( WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) ) );
// On a network, only a super admin can activate plugins unless the network enables the plugins menu.
$super_admin = $this->make_user( 'acme_requirement_super_admin', 'administrator' );
diff --git a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php
index d91f99a..8c27017 100644
--- a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php
+++ b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Tests\Unit\Core\Logging;
use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -14,7 +15,7 @@ public function test_without_wp_debug_a_record_below_warning_is_dropped(): void
$previous = \ini_set( 'error_log', $log_file );
try {
- new ErrorLogLogger( 'acme' )->info( 'Cache is cold.' );
+ new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->info( 'Cache is cold.' );
$log = \file_get_contents( $log_file );
} finally {
\ini_set( 'error_log', (string) $previous );
diff --git a/tests/Unit/Core/PluginIdentityTest.php b/tests/Unit/Core/PluginIdentityTest.php
new file mode 100644
index 0000000..e559485
--- /dev/null
+++ b/tests/Unit/Core/PluginIdentityTest.php
@@ -0,0 +1,40 @@
+hook_root );
+ }
+
+ public static function invalid_prefixes(): array {
+ return array(
+ 'empty' => array( '' ),
+ 'uppercase' => array( 'Acme' ),
+ 'leading-digit' => array( '1acme' ),
+ 'hyphen' => array( 'ac-me' ),
+ 'trailing-newline' => array( "acme\n" ),
+ '33-characters' => array( 'a' . \str_repeat( 'b', 32 ) ),
+ );
+ }
+
+ #[DataProvider( 'invalid_prefixes' )]
+ public function test_a_prefix_outside_the_pattern_is_rejected( string $prefix ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ new PluginIdentity( '/plugins/acme/acme.php', 'acme', $prefix );
+ }
+
+ public function test_a_prefix_may_reach_32_characters(): void {
+ $prefix = 'a' . \str_repeat( 'b', 31 );
+
+ self::assertSame( $prefix, new PluginIdentity( '/plugins/acme/acme.php', 'acme', $prefix )->prefix );
+ }
+}
From 9be90bf4a9666700805207f058bd5821307779fa Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 20:55:30 +0100
Subject: [PATCH 21/23] Pass the plugin's identity to the framework as one
object
Every framework class that needed the plugin's entry file, slug or
prefix took them as separate strings, so a consumer could swap two of
them without a type error, the slug-to-hook-root rule lived in six
classes across three packages, and the names the framework stores were
loose arguments that only convention kept under the plugin's prefix.
Core's new PluginIdentity holds the entry file, the slug and the prefix,
derives the hook root and refuses a prefix outside the storage pattern.
PluginBoot, AdminNotices, MigrationRunner, Assets, Scheduler, both
loggers, AbstractLogger's format_record(), CapabilityGrants, Lock, both
caches, WooCommerceRequirement::check(), Uninstaller::run() and the
three surfaces take it. The runner stores {prefix}_revision, the grants
{prefix}_capabilities, a lock {prefix}_{name}, and each cache a region
under the prefix; every log channel is the slug. AdminNotices keeps only
its reserved segments. AGENTS.md, conventions 2, 3 and 8 and the README
excerpts follow.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 10 +--
packages/core/README.md | 25 +++---
.../core/src/AdminNotice/AdminNotices.php | 78 +++++++------------
packages/core/src/Asset/Assets.php | 16 ++--
packages/core/src/Caching/ObjectCache.php | 30 +++++--
packages/core/src/Caching/TransientCache.php | 32 +++++---
packages/core/src/Logging/AbstractLogger.php | 15 ++--
packages/core/src/Logging/ErrorLogLogger.php | 7 +-
packages/core/src/Logging/GuardedLogger.php | 9 ++-
.../core/src/Migration/MigrationRunner.php | 34 ++++----
.../core/src/Permission/CapabilityGrants.php | 30 +++++--
packages/core/src/PluginBoot.php | 33 +++-----
packages/core/src/PluginIdentity.php | 60 ++++++++++++++
packages/core/src/Scheduling/Scheduler.php | 15 ++--
packages/core/src/Storage/Lock.php | 34 +++++---
packages/core/src/Uninstall/Uninstaller.php | 19 ++---
packages/settings/README.md | 12 ++-
.../settings/src/AdminPage/OptionsPage.php | 7 +-
.../settings/src/EditScreen/ObjectForm.php | 7 +-
packages/woocommerce/README.md | 14 ++--
.../src/Logging/WooCommerceLogger.php | 9 ++-
.../src/ProductData/ProductDataTab.php | 7 +-
.../src/WooCommerceRequirement.php | 13 ++--
23 files changed, 309 insertions(+), 207 deletions(-)
create mode 100644 packages/core/src/PluginIdentity.php
diff --git a/AGENTS.md b/AGENTS.md
index 2007300..8b02c52 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ wordpress-framework/
├── packages/shared/ ahegyes/wp-framework-shared `require.php` · no package require · WordPress-free
│ └── src/ psr-4 DeepWebSolutions\Framework\Shared\ · Error/, Exception/, Result/, ValueObject/, Versioning/
├── packages/core/ ahegyes/wp-framework-core `require.php`, ext-filter · bootstrap ^2.0, shared ^2.0, psr/clock ^1, psr/container ^2.0.2, psr/log ^3.0.2, psr/simple-cache ^3
-│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot (is_development_environment (@internal)), ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface
+│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot (is_development_environment (@internal)), PluginIdentity, ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface
│ ├── AdminNotice/{AdminNotices, Notice, NoticeType, NoticeScope}
│ ├── Asset/Assets
│ ├── Caching/{TransientCache, ObjectCache, CacheEntries (@internal), Exceptions/InvalidCacheKeyException}
@@ -77,13 +77,13 @@ actionlint && zizmor .github/
## Conventions
1. **Gates and toggles.** `should_load()` is static, pure and total: no hooks, writes, notices, translation, user reads or construction. Read options with an explicit default. Detect companions by include-time constants, classes or functions, aware of network activation. Gates read no request-type facts, because `REST_REQUEST` is defined only on `parse_request` and `is_admin()` holds in admin-ajax requests; callbacks check the request type. A feature's toggle and settings UI live outside its gate; a gate mixing an environment check with a setting exposes the environment half as a static method.
-2. **Nodes and the container.** Node classes never extend, implement or `use` optional or late-loaded types. Never constructor-inject a gated component; inject ungated services. Every injected service that implements `ComponentInterface` is also a node, because only nodes run `register_hooks()`. Definitions are static closures, taking `ContainerInterface $c` only when they read the container, or autowiring, never PHP-DI helpers. Hold the built container as `ContainerInterface`. Container reads belong in the kernel and composition wiring: a class-entry read checks the entry's type before use, and a factory collecting contributors re-applies their `should_load()` before resolving them. Framework classes take their logger injected; the composition root chooses the logger. A failure is logged with its Throwable under `exception`, a `WP_Error`'s Throwable data included. The uninstall engine runs without a composition root, so it writes the jobs it could not unschedule to PHP's error log, one line per site.
-3. **Constructors and `register_hooks()`.** Constructors take services only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation.
+2. **Nodes and the container.** Node classes never extend, implement or `use` optional or late-loaded types. Never constructor-inject a gated component; inject ungated services. Every injected service that implements `ComponentInterface` is also a node, because only nodes run `register_hooks()`. Definitions are static closures, taking `ContainerInterface $c` only when they read the container, or autowiring, never PHP-DI helpers. Hold the built container as `ContainerInterface`. Container reads belong in the kernel and composition wiring: a class-entry read checks the entry's type before use, and a factory collecting contributors re-applies their `should_load()` before resolving them. Framework classes take their logger injected; the composition root chooses the logger. A framework class that needs the plugin's entry file, slug or prefix takes its `PluginIdentity`, which the composition root builds once from its constants and defines in the container; only bootstrap's by-path classes take the entry file. A failure is logged with its Throwable under `exception`, a `WP_Error`'s Throwable data included. The uninstall engine runs without a composition root, so it writes the jobs it could not unschedule to PHP's error log, one line per site.
+3. **Constructors and `register_hooks()`.** Constructors take services and the plugin's identity only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation.
4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the project controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type.
5. **Safeguards.** A plugin's safeguards are one final class in `Safeguards.php` at the plugin root, beside the entry, which no autoload root maps; everything a safeguard calls lives in it or files it requires. The entry requires it by path before `load.php` and the floor gate and calls its one public static entry point, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, and, in a plugin that requires WooCommerce, WooCommerce APIs present since 3.6, guarding anything newer. A safeguard that calls WooCommerce is hooked from the class's one `woocommerce_loaded` callback, since WordPress loads a plugin while WooCommerce is inactive. Each safeguard is a static closure, which no code can remove by name, so relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`; helpers are protected, and only what other classes call is public.
6. **Shared logic.** The global namespace holds only a plugin's contract. `functions.php` declares its `@api` functions, the accessor among them, which returns the composition root while the root's members stay internal unless tagged `@api`, and registers its hooks as static closures; `includes/`, which `functions.php` loads, holds more `@api` functions and constants, grouped by topic. Every other symbol is a member of a final class in `src/` named for its concept: the composition root, which also holds the footprint, a component, or a class that registers no hooks, such as `Options`, whose constants name the stored settings and their defaults and whose public statics read them, and `Migrations`, whose one public method lists its protected static steps as first-class callables, while a batched step is a final class of its own and a meta key stays on the class that stores it. Built scripts and styles register through core's `Assets`, which the container defines. No namespace or folder is named for being internal. Per-request state lives in a static holder or `WC()->session`. No service locator.
7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant.
-8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components store data only under the consumer's `{prefix}_`, so the plugin's footprint covers it; a `TransientCache` prefix and any generation option it names start with it, so the footprint covers the generation option, and its entries, transients that all carry a timeout, expire on their own. An `ObjectCache` group starts with it too, which keeps it apart from core's and other plugins' groups. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`.
+8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components build every option, transient and meta name they store from the identity's prefix, as `{prefix}_`, so the plugin's footprint covers it: the runner's options, which start with `{prefix}_revision`, the grants' `{prefix}_capabilities` ledger, a `Lock`'s `{prefix}_{name}`, and a `TransientCache` region's generation option, `{prefix}_{generation}_cache_generation`, whose entries, transients that all carry a timeout, expire on their own. An `ObjectCache` region's group, `{prefix}_{region}`, keeps it apart from core's and other plugins' groups. The runner's scheduled hook, `{hook_root}/migration/run`, and the scheduler's Action Scheduler group, the bare `{prefix}`, fall outside it, so the footprint names them under `cron_hooks` and `as_groups`. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`.
9. **Object forms** render through `Forms::render_control()`, print their nonce bound to the object and verify it before they evaluate their definition; every framework saver goes through `Forms::sanitize_fields()` and leaves absent (unrendered or disabled) fields untouched, but for limitation 4's WooCommerce checkboxes and multiselects.
10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/`.
@@ -113,7 +113,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 `{root}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Components gated on the data revision 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.
+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 `{prefix}_revision_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 cancel Action Scheduler's default store cannot write repeats until the request's time limit; 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, consumer-a's entry, which requires it inside its one `try`, shows the build notice, or rethrows in local and development environments, while an entry that requires it outside a `try` fatals at include; the uninstall shim keeps the data on a web request, while WP-CLI fails and keeps the files. On PHP 7.4 a failed `require` is fatal at include either way. 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/README.md b/packages/core/README.md
index b9da361..e01023d 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -109,13 +109,14 @@ function acme_pc_plugin(): Plugin {
}
```
-One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's final `Migrations` class returns as first-class callables to its protected statics, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks:
+One composition root builds the plugin. It builds one `PluginIdentity` from the entry file, the slug and the prefix, and hands it to every framework class it builds, in place of those strings: the slug, with underscores for its dashes, roots the plugin's hook names and names its REST namespace, and the prefix starts every option, transient and meta name the framework stores for the plugin. The container defines the identity, so a component that builds a settings page or form takes it by constructor. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's final `Migrations` class returns as first-class callables to its protected statics, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks:
```php
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot;
+use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginKernel;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface;
@@ -136,9 +137,10 @@ final class Plugin {
// ...
public function __construct() {
- $this->logger = new ErrorLogLogger( self::SLUG );
- $this->scheduler = new Scheduler( self::PREFIX );
- $this->boundary = new PluginBoot( ACME_PC_FILE, self::SLUG, self::PREFIX, $this->logger );
+ $this->identity = new PluginIdentity( ACME_PC_FILE, self::SLUG, self::PREFIX );
+ $this->logger = new ErrorLogLogger( $this->identity );
+ $this->scheduler = new Scheduler( $this->identity );
+ $this->boundary = new PluginBoot( $this->identity, $this->logger );
}
// ...
@@ -150,10 +152,10 @@ final class Plugin {
// ...
protected function compose(): bool {
- $this->migrations = new MigrationRunner( ACME_PC_FILE, self::SLUG, self::PREFIX . '_revision', Migrations::get_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
- $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
+ $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() );
$this->kernel->boot( self::COMPONENTS );
return true;
@@ -161,12 +163,13 @@ final class Plugin {
// ...
- protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array {
+ protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger, Scheduler $scheduler ): array {
return array(
+ PluginIdentity::class => static fn (): PluginIdentity => $identity,
PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger,
Scheduler::class => static fn (): Scheduler => $scheduler,
SchedulerInterface::class => static fn (): Scheduler => $scheduler,
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::SLUG, self::PREFIX, $logger ),
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ),
);
}
@@ -189,9 +192,9 @@ final class Greeting implements ComponentInterface {
}
```
-`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $plugin_file, $slug )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported: a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version, and a script then with no dependencies, as a best effort.
+`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $identity )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported: a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version, and a script then with no dependencies, as a best effort.
-The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data:
+The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, builds the plugin's identity from the root's constants, and on a site below the floors or with an incomplete build it keeps the data:
```php
public const array FOOTPRINT = array(
@@ -206,7 +209,7 @@ The plugin's `uninstall.php` hands its footprint, a constant of its composition
```php
Uninstaller::run(
- $acme_pc_file,
+ $acme_pc_identity,
$acme_pc_footprint,
array(
'consent' => array(
diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php
index a19dca8..87310a3 100644
--- a/packages/core/src/AdminNotice/AdminNotices.php
+++ b/packages/core/src/AdminNotice/AdminNotices.php
@@ -4,6 +4,7 @@
use DeepWebSolutions\Framework\Core\ComponentInterface;
use DeepWebSolutions\Framework\Core\Logging\GuardedLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use DeepWebSolutions\Framework\Shared\Exception\LogicException;
use Psr\Log\LoggerInterface as PsrLoggerInterface;
@@ -21,16 +22,6 @@
final class AdminNotices implements ComponentInterface {
// region FIELDS AND CONSTANTS
- /**
- * The pattern every prefix matches.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @var string
- */
- protected const string PREFIX_PATTERN = '/\A[a-z][a-z0-9_]{0,31}\z/';
-
/**
* The segments a prefix may not contain, because the storage keys put them after a prefix, so such a prefix could name another plugin's keys.
*
@@ -71,16 +62,6 @@ final class AdminNotices implements ComponentInterface {
*/
protected bool $needs_dismissal_script = false;
- /**
- * The root of the plugin's hook names.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @var string
- */
- protected string $hook_root;
-
// endregion
// region MAGIC METHODS
@@ -91,23 +72,20 @@ final class AdminNotices implements ComponentInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param non-empty-string $slug The plugin's slug, which names its REST namespace and whose dashes become underscores in the root of its hook names.
- * @param string $prefix The plugin's prefix: a lowercase letter followed by up to 31 lowercase letters, digits and underscores, with no network or hidden segment.
- * @param PsrLoggerInterface $logger The logger that records failures to read, write or filter notices.
+ * @param PluginIdentity $identity The plugin's identity, whose prefix has no network or hidden segment.
+ * @param PsrLoggerInterface $logger The logger that records failures to read, write or filter notices.
*
- * @throws InvalidArgumentException Thrown when the prefix is invalid.
+ * @throws InvalidArgumentException Thrown when the prefix has a network or hidden segment.
*/
public function __construct(
- protected string $slug,
- protected string $prefix,
+ protected PluginIdentity $identity,
protected PsrLoggerInterface $logger
) {
- if ( 1 !== \preg_match( self::PREFIX_PATTERN, $prefix ) || array() !== \array_intersect( \explode( '_', $prefix ), self::RESERVED_SEGMENTS ) ) {
- throw new InvalidArgumentException( "Notices prefix '$prefix' is not a lowercase letter followed by up to 31 lowercase letters, digits and underscores, or it has a 'network' or 'hidden' segment." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ if ( array() !== \array_intersect( \explode( '_', $identity->prefix ), self::RESERVED_SEGMENTS ) ) {
+ throw new InvalidArgumentException( "Notices prefix '$identity->prefix' has a 'network' or 'hidden' segment." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
- $this->hook_root = \str_replace( '-', '_', $slug );
- $this->logger = new GuardedLogger( $logger, $prefix );
+ $this->logger = new GuardedLogger( $logger, $identity );
}
// endregion
@@ -225,7 +203,7 @@ public function render_notices(): void {
foreach ( $this->select( false ) as $notice ) {
$attributes = array(
- 'data-notices' => $this->prefix,
+ 'data-notices' => $this->identity->prefix,
'data-notice-scope' => $notice->scope->value,
'data-notice-id' => $notice->id,
);
@@ -306,10 +284,10 @@ public function enqueue_delivery_script(): void {
return;
}
- $handle = "$this->prefix-notice-delivery";
+ $handle = "{$this->identity->prefix}-notice-delivery";
$settings = wp_json_encode(
array(
- 'prefix' => $this->prefix,
+ 'prefix' => $this->identity->prefix,
'path' => "{$this->get_rest_namespace()}/notices/deliveries",
'waiting' => array() !== $this->select( true ),
)
@@ -371,10 +349,10 @@ public function enqueue_dismissal_script(): void {
return;
}
- $handle = "$this->prefix-notice-dismissal";
+ $handle = "{$this->identity->prefix}-notice-dismissal";
$settings = wp_json_encode(
array(
- 'prefix' => $this->prefix,
+ 'prefix' => $this->identity->prefix,
'path' => "{$this->get_rest_namespace()}/notices/dismissals",
)
);
@@ -466,7 +444,7 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP
* @param int $user_id The user who dismissed it.
* @param int $owner The notice's owner: 0 for a request notice, otherwise the user, blog or network id.
*/
- do_action( "$this->hook_root/admin_notice/dismissed", $id, $scope->value, $user_id, $owner );
+ do_action( "{$this->identity->hook_root}/admin_notice/dismissed", $id, $scope->value, $user_id, $owner );
}
return new \WP_REST_Response( array( 'changed' => $changed ) );
@@ -563,7 +541,7 @@ protected function select( bool $block_editor ): array {
* @param string $id The notice's id.
* @param string $scope The notice's scope.
*/
- if ( true === apply_filters( "$this->hook_root/admin_notice/should_show", true, $notice->id, $scope->value ) ) {
+ if ( true === apply_filters( "{$this->identity->hook_root}/admin_notice/should_show", true, $notice->id, $scope->value ) ) {
$selected[] = $notice;
}
}
@@ -654,9 +632,9 @@ protected function get_owner( NoticeScope $scope, int $user_id ): int {
protected function read( NoticeScope $scope, int $owner ): array {
$rows = match ( $scope ) {
NoticeScope::Request => $this->request_notices,
- NoticeScope::User => get_user_option( "{$this->prefix}_notices", $owner ),
- NoticeScope::Site => get_option( "{$this->prefix}_notices" ),
- NoticeScope::Network => get_network_option( $owner, "{$this->prefix}_network_notices" ),
+ NoticeScope::User => get_user_option( "{$this->identity->prefix}_notices", $owner ),
+ NoticeScope::Site => get_option( "{$this->identity->prefix}_notices" ),
+ NoticeScope::Network => get_network_option( $owner, "{$this->identity->prefix}_network_notices" ),
};
$notices = array();
@@ -688,9 +666,9 @@ protected function write( NoticeScope $scope, int $owner, array $notices ): void
$rows = \array_map( static fn ( Notice $notice ): array => $notice->to_array(), $notices );
match ( $scope ) {
NoticeScope::Request => $this->request_notices = $rows,
- NoticeScope::User => update_user_option( $owner, "{$this->prefix}_notices", wp_slash( $rows ) ),
- NoticeScope::Site => update_option( "{$this->prefix}_notices", $rows, false ),
- NoticeScope::Network => update_network_option( $owner, "{$this->prefix}_network_notices", $rows ),
+ NoticeScope::User => update_user_option( $owner, "{$this->identity->prefix}_notices", wp_slash( $rows ) ),
+ NoticeScope::Site => update_option( "{$this->identity->prefix}_notices", $rows, false ),
+ NoticeScope::Network => update_network_option( $owner, "{$this->identity->prefix}_network_notices", $rows ),
};
}
@@ -731,7 +709,7 @@ protected function delete( NoticeScope $scope, int $owner, string $id ): bool {
* @return array
*/
protected function get_hidden( NoticeScope $scope, int $user_id ): array {
- $stored = NoticeScope::Network === $scope ? get_user_meta( $user_id, "{$this->prefix}_network_hidden_notices", true ) : get_user_option( "{$this->prefix}_hidden_notices", $user_id );
+ $stored = NoticeScope::Network === $scope ? get_user_meta( $user_id, "{$this->identity->prefix}_network_hidden_notices", true ) : get_user_option( "{$this->identity->prefix}_hidden_notices", $user_id );
$hidden = array();
foreach ( \is_array( $stored ) ? $stored : array() as $key => $entry ) {
@@ -786,9 +764,9 @@ protected function hide( NoticeScope $scope, string $id, int $user_id, string $s
'at' => \time(),
);
if ( NoticeScope::Network === $scope ) {
- update_user_meta( $user_id, "{$this->prefix}_network_hidden_notices", wp_slash( $hidden ) );
+ update_user_meta( $user_id, "{$this->identity->prefix}_network_hidden_notices", wp_slash( $hidden ) );
} else {
- update_user_option( $user_id, "{$this->prefix}_hidden_notices", wp_slash( $hidden ) );
+ update_user_option( $user_id, "{$this->identity->prefix}_hidden_notices", wp_slash( $hidden ) );
}
return true;
@@ -815,7 +793,7 @@ protected function get_message( Notice $notice ): string {
* @param string $id The notice's id.
* @param string $scope The notice's scope.
*/
- $message = apply_filters( "$this->hook_root/admin_notice/message", $notice->message, $notice->id, $notice->scope->value );
+ $message = apply_filters( "{$this->identity->hook_root}/admin_notice/message", $notice->message, $notice->id, $notice->scope->value );
return \is_string( $message ) ? $message : $notice->message;
}
@@ -853,7 +831,7 @@ protected function log( string $message, NoticeScope $scope, \Throwable $excepti
* @return string
*/
protected function get_dom_id( Notice $notice ): string {
- return "$this->prefix-{$notice->scope->value}-notice-$notice->id";
+ return "{$this->identity->prefix}-{$notice->scope->value}-notice-$notice->id";
}
/**
@@ -865,7 +843,7 @@ protected function get_dom_id( Notice $notice ): string {
* @return non-falsy-string
*/
protected function get_rest_namespace(): string {
- return "$this->slug/v1";
+ return "{$this->identity->slug}/v1";
}
/**
@@ -880,7 +858,7 @@ protected function get_rest_namespace(): string {
* @return string
*/
protected function get_nonce_action( NoticeScope $scope, string $id ): string {
- return "{$this->prefix}_dismiss|" . get_current_blog_id() . ":$scope->value:$id";
+ return "{$this->identity->prefix}_dismiss|" . get_current_blog_id() . ":$scope->value:$id";
}
// endregion
diff --git a/packages/core/src/Asset/Assets.php b/packages/core/src/Asset/Assets.php
index 23bb0d2..0119ec8 100644
--- a/packages/core/src/Asset/Assets.php
+++ b/packages/core/src/Asset/Assets.php
@@ -2,6 +2,8 @@
namespace DeepWebSolutions\Framework\Core\Asset;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
+
\defined( 'ABSPATH' ) || exit;
/**
@@ -19,12 +21,10 @@ final class Assets {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $plugin_file The path to the plugin's entry file, whose directory holds the built files and languages/.
- * @param non-empty-string $slug The plugin's slug, which is its text domain.
+ * @param PluginIdentity $identity The plugin's identity, whose entry file's directory holds the built files and languages/, and whose slug is its text domain.
*/
public function __construct(
- protected string $plugin_file,
- protected string $slug
+ protected PluginIdentity $identity
) {}
// endregion
@@ -49,9 +49,9 @@ public function register_script( string $handle, string $path, array $args = arr
$asset = $this->get_asset( $path );
// Script modules the build imports are not script handles, so they stay out of the dependencies.
- $registered = wp_register_script( $handle, plugin_dir_url( $this->plugin_file ) . $path, \array_values( \array_filter( $asset['dependencies'], 'is_string' ) ), $asset['version'], $args );
+ $registered = wp_register_script( $handle, plugin_dir_url( $this->identity->file ) . $path, \array_values( \array_filter( $asset['dependencies'], 'is_string' ) ), $asset['version'], $args );
if ( $registered ) {
- wp_set_script_translations( $handle, $this->slug, plugin_dir_path( $this->plugin_file ) . 'languages' );
+ wp_set_script_translations( $handle, $this->identity->slug, plugin_dir_path( $this->identity->file ) . 'languages' );
}
return $registered;
@@ -69,7 +69,7 @@ public function register_script( string $handle, string $path, array $args = arr
* @return bool
*/
public function register_style( string $handle, string $path ): bool {
- $registered = wp_register_style( $handle, plugin_dir_url( $this->plugin_file ) . $path, array(), $this->get_asset( $path )['version'] );
+ $registered = wp_register_style( $handle, plugin_dir_url( $this->identity->file ) . $path, array(), $this->get_asset( $path )['version'] );
if ( $registered ) {
wp_style_add_data( $handle, 'rtl', 'replace' );
}
@@ -94,7 +94,7 @@ public function register_style( string $handle, string $path ): bool {
* @return array{dependencies: list, version: string}
*/
protected function get_asset( string $path ): array {
- $file = plugin_dir_path( $this->plugin_file ) . $path;
+ $file = plugin_dir_path( $this->identity->file ) . $path;
$directory = \dirname( $file );
$name = \pathinfo( $file, \PATHINFO_FILENAME );
$asset_file = "$directory/$name.asset.php";
diff --git a/packages/core/src/Caching/ObjectCache.php b/packages/core/src/Caching/ObjectCache.php
index e2dbaae..3dd876f 100644
--- a/packages/core/src/Caching/ObjectCache.php
+++ b/packages/core/src/Caching/ObjectCache.php
@@ -3,13 +3,14 @@
namespace DeepWebSolutions\Framework\Core\Caching;
use DeepWebSolutions\Framework\Core\Caching\Exceptions\InvalidCacheKeyException;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use Psr\SimpleCache\CacheInterface as PsrCacheInterface;
\defined( 'ABSPATH' ) || exit;
/**
- * Caches values in a group of the current site's object cache, and clears them all at once by changing the group's salt.
+ * Caches values in a group of the current site's object cache named after a region under the plugin's prefix, and clears them all at once by changing the group's salt.
*
* Each entry is named by its key's hash, which never equals the group's last_changed key, and holds the salt it was stored under, so that clear() leaves one entry per key for the object cache to evict.
*
@@ -17,6 +18,20 @@
* @version 2.0.0
*/
final class ObjectCache implements PsrCacheInterface {
+ // region FIELDS AND CONSTANTS
+
+ /**
+ * The object cache group: the plugin's prefix, an underscore and the region.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var string
+ */
+ protected string $group;
+
+ // endregion
+
// region MAGIC METHODS
/**
@@ -25,15 +40,18 @@ final class ObjectCache implements PsrCacheInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $group The object cache group, under the plugin's own prefix.
+ * @param PluginIdentity $identity The identity of the plugin that owns the cache.
+ * @param string $region The cache's region, which follows the plugin's prefix and an underscore in the group's name.
*
- * @throws InvalidArgumentException Thrown when the group is invalid.
+ * @throws InvalidArgumentException Thrown when the group's name is invalid.
*/
public function __construct(
- protected string $group
+ PluginIdentity $identity,
+ string $region
) {
- if ( 1 !== \preg_match( '/\A[a-z][a-z0-9_-]{0,63}\z/', $group ) ) {
- throw new InvalidArgumentException( "Group '$group' is not a lowercase letter followed by up to 63 lowercase letters, digits, underscores and hyphens." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ $this->group = "{$identity->prefix}_$region";
+ if ( 1 !== \preg_match( '/\A[a-z][a-z0-9_-]{0,63}\z/', $this->group ) ) {
+ throw new InvalidArgumentException( "Group '$this->group' is not a lowercase letter followed by up to 63 lowercase letters, digits, underscores and hyphens." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
}
diff --git a/packages/core/src/Caching/TransientCache.php b/packages/core/src/Caching/TransientCache.php
index 4e1ae3a..1d279a7 100644
--- a/packages/core/src/Caching/TransientCache.php
+++ b/packages/core/src/Caching/TransientCache.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Core\Caching;
use DeepWebSolutions\Framework\Core\Caching\Exceptions\InvalidCacheKeyException;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Storage\OptionRows;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use Psr\SimpleCache\CacheInterface as PsrCacheInterface;
@@ -10,7 +11,7 @@
\defined( 'ABSPATH' ) || exit;
/**
- * Caches values as transients of the current site under a prefix, and clears them all at once by starting a new generation.
+ * Caches values as transients of the current site in a region under the plugin's prefix, and clears them all at once by starting a new generation.
*
* Each entry is named by its key's hash, because the options table matches names regardless of letter case and accents and drops the timeout of a transient name past 172 characters. Each also holds the generation it was stored under, so that clear() leaves one transient per key rather than one per generation.
*
@@ -31,7 +32,7 @@ final class TransientCache implements PsrCacheInterface {
protected const int DEFAULT_TTL = DAY_IN_SECONDS;
/**
- * The length of the longest prefix, which leaves room for a key's hash within the 172 characters of a transient name.
+ * The length of the longest prefix of the transient names, which leaves room for a key's hash within the 172 characters of a transient name.
*
* @since 2.0.0
* @version 2.0.0
@@ -50,6 +51,16 @@ final class TransientCache implements PsrCacheInterface {
*/
protected string $generation_option;
+ /**
+ * The prefix of every transient name: the plugin's prefix, the region and an underscore after each.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var string
+ */
+ protected string $prefix;
+
// endregion
// region MAGIC METHODS
@@ -60,17 +71,20 @@ final class TransientCache implements PsrCacheInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $prefix The prefix of every transient name, under the plugin's own prefix.
- * @param string|null $generation_option Optional. The option that keeps the generation, which caches that clear together share. Default is the prefix followed by cache_generation.
+ * @param PluginIdentity $identity The identity of the plugin that owns the cache.
+ * @param string $region The cache's region, which follows the plugin's prefix and an underscore in every transient name.
+ * @param string|null $generation Optional. The region whose generation the cache reads, which caches that clear together share, kept in the option {prefix}_{generation}_cache_generation. Default is the cache's own region.
*
- * @throws InvalidArgumentException Thrown when the prefix or the generation option name is invalid.
+ * @throws InvalidArgumentException Thrown when the region leaves no room for a key's hash in a transient name, or the generation's option name is invalid.
*/
public function __construct(
- protected string $prefix,
- ?string $generation_option = null
+ PluginIdentity $identity,
+ string $region,
+ ?string $generation = null
) {
- OptionRows::check_name( $prefix, self::MAX_PREFIX_LENGTH );
- $this->generation_option = $generation_option ?? "{$prefix}cache_generation";
+ $this->prefix = "{$identity->prefix}_{$region}_";
+ OptionRows::check_name( $this->prefix, self::MAX_PREFIX_LENGTH );
+ $this->generation_option = "{$identity->prefix}_" . ( $generation ?? $region ) . '_cache_generation';
OptionRows::check_name( $this->generation_option );
}
diff --git a/packages/core/src/Logging/AbstractLogger.php b/packages/core/src/Logging/AbstractLogger.php
index 27a78f0..0c10d1e 100644
--- a/packages/core/src/Logging/AbstractLogger.php
+++ b/packages/core/src/Logging/AbstractLogger.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Core\Logging;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use Psr\Log\AbstractLogger as PsrAbstractLogger;
use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException;
use Psr\Log\LogLevel as PsrLogLevel;
@@ -107,21 +108,21 @@ protected static function interpolate( string $message, array $context ): string
}
/**
- * Returns the record as one entry of the PHP error log: the channel and level, the message, and any Throwable under the context's exception key on the lines after.
+ * Returns the record as one entry of the PHP error log: the plugin's slug and the level, the message, and any Throwable under the context's exception key on the lines after.
*
* @since 2.0.0
* @version 2.0.0
*
- * @param string $channel The name that identifies the plugin.
- * @param string $level The record's level.
- * @param string $message The message as it is written.
- * @param array $context The record's context.
+ * @param PluginIdentity $identity The identity of the plugin the record belongs to.
+ * @param string $level The record's level.
+ * @param string $message The message as it is written.
+ * @param array $context The record's context.
*
* @return string
*/
- protected static function format_record( string $channel, string $level, string $message, array $context ): string {
+ protected static function format_record( PluginIdentity $identity, string $level, string $message, array $context ): string {
$exception = $context['exception'] ?? null;
- return "[$channel.$level] $message" . ( $exception instanceof \Throwable ? "\n$exception" : '' );
+ return "[$identity->slug.$level] $message" . ( $exception instanceof \Throwable ? "\n$exception" : '' );
}
// endregion
diff --git a/packages/core/src/Logging/ErrorLogLogger.php b/packages/core/src/Logging/ErrorLogLogger.php
index 0855606..b486ea1 100644
--- a/packages/core/src/Logging/ErrorLogLogger.php
+++ b/packages/core/src/Logging/ErrorLogLogger.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Core\Logging;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException;
\defined( 'ABSPATH' ) || exit;
@@ -21,10 +22,10 @@ final class ErrorLogLogger extends AbstractLogger {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $channel The name that identifies the plugin in every record.
+ * @param PluginIdentity $identity The identity of the plugin, whose slug starts every record.
*/
public function __construct(
- protected string $channel
+ protected PluginIdentity $identity
) {}
// endregion
@@ -54,7 +55,7 @@ public function log( mixed $level, string|\Stringable $message, array $context =
return;
}
- \error_log( self::format_record( $this->channel, $level, self::interpolate( (string) $message, $context ), $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Writing to the error log is this logger's purpose.
+ \error_log( self::format_record( $this->identity, $level, self::interpolate( (string) $message, $context ), $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Writing to the error log is this logger's purpose.
do_action( "qm/$level", $message, $context );
}
diff --git a/packages/core/src/Logging/GuardedLogger.php b/packages/core/src/Logging/GuardedLogger.php
index f158f1f..8d3779b 100644
--- a/packages/core/src/Logging/GuardedLogger.php
+++ b/packages/core/src/Logging/GuardedLogger.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Core\Logging;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException;
use Psr\Log\LoggerInterface as PsrLoggerInterface;
@@ -26,12 +27,12 @@ final class GuardedLogger extends AbstractLogger {
* @since 2.0.0
* @version 2.0.0
*
- * @param PsrLoggerInterface $logger The logger that receives every record.
- * @param string $channel The name that identifies the plugin in a record written to the PHP error log.
+ * @param PsrLoggerInterface $logger The logger that receives every record.
+ * @param PluginIdentity $identity The identity of the plugin, whose slug starts a record written to the PHP error log.
*/
public function __construct(
protected PsrLoggerInterface $logger,
- protected string $channel
+ protected PluginIdentity $identity
) {}
// endregion
@@ -61,7 +62,7 @@ public function log( mixed $level, string|\Stringable $message, array $context =
} catch ( \Throwable $failure ) {
// The raw message with its context as JSON keeps the values that no placeholder names.
$encoded = wp_json_encode( \array_diff_key( $context, array( 'exception' => true ) ) );
- \error_log( self::format_record( $this->channel, $level, "$message $encoded", $context ) . "\nThe logger failed: $failure" ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The wrapped logger failed.
+ \error_log( self::format_record( $this->identity, $level, "$message $encoded", $context ) . "\nThe logger failed: $failure" ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The wrapped logger failed.
}
}
diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php
index 3aff7a6..13db3dd 100644
--- a/packages/core/src/Migration/MigrationRunner.php
+++ b/packages/core/src/Migration/MigrationRunner.php
@@ -7,6 +7,7 @@
use DeepWebSolutions\Framework\Core\ComponentInterface;
use DeepWebSolutions\Framework\Core\Logging\GuardedLogger;
use DeepWebSolutions\Framework\Core\PluginBoot;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook;
use DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface;
use DeepWebSolutions\Framework\Core\Storage\Lock;
@@ -42,6 +43,16 @@ final class MigrationRunner implements ComponentInterface {
*/
protected const int LOCK_TTL = 600;
+ /**
+ * The option that stores the data revision, which starts the name of every other option the runner owns.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var string
+ */
+ protected string $option;
+
/**
* The clock that the lock and the batch budget read.
*
@@ -63,7 +74,7 @@ final class MigrationRunner implements ComponentInterface {
protected Lock $lock;
/**
- * The hook that runs the steps in the background, built once so that an option too long to name it fails at construction.
+ * The hook that runs the steps in the background, built once so that a slug too long to name it fails at construction.
*
* @since 2.0.0
* @version 2.0.0
@@ -102,34 +113,29 @@ final class MigrationRunner implements ComponentInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $plugin_file The path to the plugin's entry file.
- * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the runner's hook.
- * @param string $option The option that stores the data revision, and the prefix of every other option the runner owns.
+ * @param PluginIdentity $identity The plugin's identity, whose prefix starts the option that stores the data revision, {prefix}_revision.
* @param list<(callable(): void)|BatchedMigrationStepInterface> $steps The migration steps, append-only, where index 0 brings the data to revision 1.
* @param PsrLoggerInterface $logger The logger that records failures and warnings.
* @param SchedulerInterface $scheduler The scheduler that runs the runner's hook in the background.
* @param int $batch_time_limit Optional. The seconds, from 1, that a request spends on steps and batches before it pauses. Default is 20.
* @param PsrClockInterface|null $clock Optional. The clock. Default is the system clock.
*
- * @throws InvalidArgumentException Thrown when the option cannot prefix every option the runner owns, or the slug cannot root the scheduled hook.
+ * @throws InvalidArgumentException Thrown when the slug cannot root the scheduled hook.
*/
public function __construct(
- protected string $plugin_file,
- protected string $slug,
- protected string $option,
+ protected PluginIdentity $identity,
protected array $steps,
protected PsrLoggerInterface $logger,
protected SchedulerInterface $scheduler,
protected int $batch_time_limit = 20,
?PsrClockInterface $clock = null
) {
- OptionRows::check_name( "{$option}_high_water" ); // The longest option the runner owns.
-
+ $this->option = "{$identity->prefix}_revision";
$this->clock = $clock ?? new SystemClock();
- $this->lock = new Lock( "{$option}_lock", self::LOCK_TTL, $this->clock );
+ $this->lock = new Lock( $identity, 'revision_lock', self::LOCK_TTL, $this->clock );
$this->scheduled_hook = new ScheduledHook( $this->get_hook() );
$this->owner = wp_generate_uuid4();
- $this->logger = new GuardedLogger( $logger, $option );
+ $this->logger = new GuardedLogger( $logger, $identity );
}
// endregion
@@ -338,7 +344,7 @@ public function render_notice(): void {
return;
}
- $name = get_plugin_data( $this->plugin_file, false )['Name'];
+ $name = get_plugin_data( $this->identity->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" ) );
@@ -584,7 +590,7 @@ protected function get_time(): float {
* @return non-empty-string
*/
protected function get_hook(): string {
- return \str_replace( '-', '_', $this->slug ) . '/migration/run';
+ return "{$this->identity->hook_root}/migration/run";
}
// endregion
diff --git a/packages/core/src/Permission/CapabilityGrants.php b/packages/core/src/Permission/CapabilityGrants.php
index 3e5165a..5f37283 100644
--- a/packages/core/src/Permission/CapabilityGrants.php
+++ b/packages/core/src/Permission/CapabilityGrants.php
@@ -4,7 +4,7 @@
use DeepWebSolutions\Framework\Core\ComponentInterface;
use DeepWebSolutions\Framework\Core\Logging\GuardedLogger;
-use DeepWebSolutions\Framework\Core\Storage\OptionRows;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use Psr\Log\LoggerInterface as PsrLoggerInterface;
@@ -21,6 +21,20 @@
* @phpstan-type Pairs array>
*/
final class CapabilityGrants implements ComponentInterface {
+ // region FIELDS AND CONSTANTS
+
+ /**
+ * The option that stores, on each site, the fingerprint of the last run and the grants made, autoloaded: the plugin's prefix followed by _capabilities.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var string
+ */
+ protected string $ledger_option;
+
+ // endregion
+
// region MAGIC METHODS
/**
@@ -29,19 +43,19 @@ final class CapabilityGrants implements ComponentInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $ledger_option The option that stores, on each site, the fingerprint of the last run and the grants made, autoloaded.
- * @param Pairs $grants The capabilities to grant, as lists keyed by role name.
- * @param PsrLoggerInterface $logger The logger that records the writes that did not persist.
+ * @param PluginIdentity $identity The identity of the plugin whose capabilities are granted.
+ * @param Pairs $grants The capabilities to grant, as lists keyed by role name.
+ * @param PsrLoggerInterface $logger The logger that records the writes that did not persist.
*
- * @throws InvalidArgumentException Thrown when the option name or the grants are invalid.
+ * @throws InvalidArgumentException Thrown when the grants are invalid.
*/
public function __construct(
- protected string $ledger_option,
+ PluginIdentity $identity,
protected array $grants,
protected PsrLoggerInterface $logger
) {
- OptionRows::check_name( $ledger_option );
- $this->logger = new GuardedLogger( $logger, $ledger_option );
+ $this->ledger_option = "{$identity->prefix}_capabilities";
+ $this->logger = new GuardedLogger( $logger, $identity );
foreach ( $grants as $role => $caps ) {
if ( ! \is_string( $role ) || '' === $role || ! \is_array( $caps ) || ! \array_is_list( $caps ) || ! \array_all( $caps, static fn ( mixed $cap ): bool => \is_string( $cap ) && '' !== $cap ) ) {
diff --git a/packages/core/src/PluginBoot.php b/packages/core/src/PluginBoot.php
index 09fa87a..89752f0 100644
--- a/packages/core/src/PluginBoot.php
+++ b/packages/core/src/PluginBoot.php
@@ -27,16 +27,6 @@ final class PluginBoot {
*/
protected(set) string $status = 'pending';
- /**
- * The root of the plugin's hook names.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @var string
- */
- protected string $hook_root;
-
// endregion
// region MAGIC METHODS
@@ -47,19 +37,14 @@ final class PluginBoot {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $plugin_file The path to the plugin's entry file.
- * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of its hook names.
- * @param string $prefix The prefix of the plugin's transients and log channel.
- * @param PsrLoggerInterface $logger The logger that records boot failures.
+ * @param PluginIdentity $identity The plugin's identity.
+ * @param PsrLoggerInterface $logger The logger that records boot failures.
*/
public function __construct(
- protected string $plugin_file,
- string $slug,
- protected string $prefix,
+ protected PluginIdentity $identity,
protected PsrLoggerInterface $logger
) {
- $this->hook_root = \str_replace( '-', '_', $slug );
- $this->logger = new GuardedLogger( $logger, $prefix );
+ $this->logger = new GuardedLogger( $logger, $identity );
}
// endregion
@@ -87,7 +72,7 @@ public function run( callable $compose ): void {
$this->status = 'failed';
// A deterministic failure repeats on every request, so each distinct message is logged about once an hour, as best effort.
- $key = "{$this->prefix}_boot_failure_" . \md5( $exception->getMessage() );
+ $key = "{$this->identity->prefix}_boot_failure_" . \md5( $exception->getMessage() );
if ( false === get_transient( $key ) ) {
set_transient( $key, 1, HOUR_IN_SECONDS );
$this->logger->error(
@@ -99,7 +84,7 @@ public function run( callable $compose ): void {
);
}
- $plugin_file = $this->plugin_file;
+ $plugin_file = $this->identity->file;
add_action(
'all_admin_notices',
static function () use ( $plugin_file ): void {
@@ -121,7 +106,7 @@ static function () use ( $plugin_file ): void {
*
* @param \Throwable $exception The failure that stopped the composition.
*/
- do_action( "$this->hook_root/boot/failed", $exception );
+ do_action( "{$this->identity->hook_root}/boot/failed", $exception );
if ( self::is_development_environment() ) {
throw $exception;
}
@@ -138,7 +123,7 @@ static function () use ( $plugin_file ): void {
* @since 2.0.0
* @version 2.0.0
*/
- do_action( "$this->hook_root/boot/blocked" );
+ do_action( "{$this->identity->hook_root}/boot/blocked" );
return;
}
@@ -150,7 +135,7 @@ static function () use ( $plugin_file ): void {
* @since 2.0.0
* @version 2.0.0
*/
- do_action( "$this->hook_root/boot/succeeded" );
+ do_action( "{$this->identity->hook_root}/boot/succeeded" );
}
/**
diff --git a/packages/core/src/PluginIdentity.php b/packages/core/src/PluginIdentity.php
new file mode 100644
index 0000000..578dc64
--- /dev/null
+++ b/packages/core/src/PluginIdentity.php
@@ -0,0 +1,60 @@
+hook_root = \str_replace( '-', '_', $slug );
+ }
+
+ // endregion
+}
diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php
index 5da63f8..9d54132 100644
--- a/packages/core/src/Scheduling/Scheduler.php
+++ b/packages/core/src/Scheduling/Scheduler.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Core\Scheduling;
use DeepWebSolutions\Framework\Core\ComponentInterface;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Core\Scheduling\Backends\ActionSchedulerBackend;
use DeepWebSolutions\Framework\Core\Scheduling\Backends\BackendInterface;
use DeepWebSolutions\Framework\Core\Scheduling\Backends\BackendState;
@@ -64,17 +65,11 @@ final class Scheduler implements SchedulerInterface, ComponentInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $group The plugin's prefix, which names its Action Scheduler group and prefixes its WP-Cron schedules.
- *
- * @throws InvalidArgumentException Thrown when the group is invalid.
+ * @param PluginIdentity $identity The plugin's identity, whose prefix names its Action Scheduler group and prefixes its WP-Cron schedules.
*/
- public function __construct( string $group ) {
- if ( 1 !== \preg_match( '/\A[a-z][a-z0-9_-]{0,189}\z/i', $group ) ) {
- throw new InvalidArgumentException( "Group '$group' is not a letter followed by up to 189 letters, digits, underscores and hyphens." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
- }
-
- $this->wp_cron = new WpCronBackend( $group );
- $this->backends = array( new ActionSchedulerBackend( $group ), $this->wp_cron );
+ public function __construct( PluginIdentity $identity ) {
+ $this->wp_cron = new WpCronBackend( $identity->prefix );
+ $this->backends = array( new ActionSchedulerBackend( $identity->prefix ), $this->wp_cron );
$this->blog_id = get_current_blog_id();
}
diff --git a/packages/core/src/Storage/Lock.php b/packages/core/src/Storage/Lock.php
index c900974..2ffbbb1 100644
--- a/packages/core/src/Storage/Lock.php
+++ b/packages/core/src/Storage/Lock.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Core\Storage;
use DeepWebSolutions\Framework\Core\Clock\SystemClock;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use DeepWebSolutions\Framework\Shared\Exception\RuntimeException;
use Psr\Clock\ClockInterface as PsrClockInterface;
@@ -32,6 +33,16 @@
*/
protected PsrClockInterface $clock;
+ /**
+ * The option that holds the lock: the plugin's prefix, an underscore and the lock's name.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var string
+ */
+ protected string $option;
+
// endregion
// region MAGIC METHODS
@@ -42,18 +53,21 @@
* @since 2.0.0
* @version 2.0.0
*
- * @param string $name The name of the option that holds the lock, under the plugin's prefix.
- * @param int $ttl The seconds that a hold lasts once it is taken or refreshed, from 1.
- * @param PsrClockInterface|null $clock Optional. The clock. Default is the system clock.
+ * @param PluginIdentity $identity The identity of the plugin that owns the lock.
+ * @param string $name The lock's name, which follows the plugin's prefix and an underscore in the option that holds the lock.
+ * @param int $ttl The seconds that a hold lasts once it is taken or refreshed, from 1.
+ * @param PsrClockInterface|null $clock Optional. The clock. Default is the system clock.
*
- * @throws InvalidArgumentException Thrown when the name is not a valid option name or the TTL is below 1 second.
+ * @throws InvalidArgumentException Thrown when the option that holds the lock is not a valid option name or the TTL is below 1 second.
*/
public function __construct(
- protected string $name,
+ PluginIdentity $identity,
+ string $name,
protected int $ttl,
?PsrClockInterface $clock = null
) {
- OptionRows::check_name( $name );
+ $this->option = "{$identity->prefix}_$name";
+ OptionRows::check_name( $this->option );
if ( $ttl < 1 ) { // A hold whose TTL is below 1 second expires as it is taken, which would end the exclusion without a sign.
throw new InvalidArgumentException( "Lock TTL '$ttl' is below 1 second." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
@@ -88,11 +102,11 @@ public function acquire( string $owner ): LockClaim {
$now = $this->clock->now()->getTimestamp();
$hold = $this->make_hold( $owner, $now );
- if ( ! \is_null( OptionRows::insert( $this->name, $hold ) ) ) {
+ if ( ! \is_null( OptionRows::insert( $this->option, $hold ) ) ) {
return LockClaim::Acquired;
}
- $row = OptionRows::get( $this->name );
+ $row = OptionRows::get( $this->option );
if ( \is_null( $row ) ) {
return LockClaim::Busy; // The row was another owner's hold until its release after the insert.
}
@@ -120,7 +134,7 @@ public function acquire( string $owner ): LockClaim {
* @return bool
*/
public function refresh( string $owner ): bool {
- $row = OptionRows::get( $this->name );
+ $row = OptionRows::get( $this->option );
if ( \is_null( $row ) || ( self::read( $row->value )['owner'] ?? null ) !== $owner ) {
return false;
}
@@ -143,7 +157,7 @@ public function refresh( string $owner ): bool {
* @return void
*/
public function release( string $owner ): void {
- $row = OptionRows::get( $this->name );
+ $row = OptionRows::get( $this->option );
if ( ! \is_null( $row ) && ( self::read( $row->value )['owner'] ?? null ) === $owner ) {
OptionRows::delete( $row );
}
diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php
index b2c8341..9f3c9b4 100644
--- a/packages/core/src/Uninstall/Uninstaller.php
+++ b/packages/core/src/Uninstall/Uninstaller.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Core\Uninstall;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Shared\Exception\RuntimeException;
\defined( 'ABSPATH' ) || exit;
@@ -52,13 +53,13 @@ final class Uninstaller {
* @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 PluginIdentity $identity The plugin's identity.
* @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 PluginIdentity $identity,
protected readonly array $footprint,
protected readonly array $meta_footprint,
protected readonly array $args
@@ -79,15 +80,15 @@ protected function __construct(
* @phpstan-param Footprint $footprint
* @phpstan-param Args $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 Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default is none, which keeps the footprint.
+ * @param PluginIdentity $identity The plugin's identity.
+ * @param array $footprint The plugin's stored data by kind.
+ * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default is none, which keeps the footprint.
*
* @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a read or delete, a site's pass or the cleanup failed, once every site was visited.
*
* @return void
*/
- public static function run( string $plugin_file, array $footprint, array $args = array() ): void {
+ public static function run( PluginIdentity $identity, array $footprint, array $args = array() ): void {
$args += array(
'consent' => array(),
'meta_types' => array(),
@@ -119,7 +120,7 @@ public static function run( string $plugin_file, array $footprint, array $args =
$args['meta_types'] = $meta_types;
$uninstaller = new self(
- $plugin_file,
+ $identity,
\array_map(
self::get_strings( ... ),
\array_diff_key(
@@ -230,7 +231,7 @@ protected function uninstall_site(): void {
// A job that cannot be unscheduled only ever fires into a missing callback, so it is logged and never holds the uninstall back.
$unscheduling = \array_splice( $this->failures, $failed );
if ( array() !== $unscheduling ) {
- \error_log( '[' . plugin_basename( $this->plugin_file ) . '] Uninstall could not unschedule: ' . \implode( '; ', $unscheduling ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- No logger exists while a plugin is uninstalled.
+ \error_log( '[' . plugin_basename( $this->identity->file ) . '] Uninstall could not unschedule: ' . \implode( '; ', $unscheduling ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- No logger exists while a plugin is uninstalled.
}
$consent = get_option( $this->args['consent']['option'] );
@@ -438,7 +439,7 @@ protected function run_cleanup( string $scope ): bool {
array(
'scope' => $scope,
'site_id' => get_current_blog_id(),
- 'plugin_file' => $this->plugin_file,
+ 'plugin_file' => $this->identity->file,
)
);
$result = null;
diff --git a/packages/settings/README.md b/packages/settings/README.md
index 27acdb9..bdfc77e 100644
--- a/packages/settings/README.md
+++ b/packages/settings/README.md
@@ -72,6 +72,8 @@ use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirement
defined( 'ABSPATH' ) || exit;
+define( 'ACME_PS_FILE', __FILE__ );
+
$acme_ps_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php';
if ( ! is_readable( $acme_ps_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it.
return;
@@ -86,13 +88,14 @@ require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/functions.php';
```
-The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin registers its settings page, and a form on the post edit screen whose rejections core's `AdminNotices` reports:
+The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin builds its `PluginIdentity` from the entry file, its slug and its prefix, and registers its settings page and a form on the post edit screen whose rejections core's `AdminNotices` reports, each taking the identity, whose slug roots their filters:
```php
use Acme\PersonaSettings\Details;
use Acme\PersonaSettings\Settings;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
+use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm;
@@ -102,11 +105,12 @@ defined( 'ABSPATH' ) || exit;
add_action(
'plugins_loaded',
static function (): void {
- $notices = new AdminNotices( Settings::SLUG, Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
+ $identity = new PluginIdentity( ACME_PS_FILE, Settings::SLUG, Settings::PREFIX );
+ $notices = new AdminNotices( $identity, new ErrorLogLogger( $identity ) );
$notices->register_hooks();
- new OptionsPage( Settings::SLUG, Settings::PREFIX, new Settings() )->register_hooks();
- new ObjectForm( Settings::SLUG, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
+ new OptionsPage( $identity, Settings::PREFIX, new Settings() )->register_hooks();
+ new ObjectForm( $identity, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
},
15
);
diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php
index 6cf13b4..41a9c3b 100644
--- a/packages/settings/src/AdminPage/OptionsPage.php
+++ b/packages/settings/src/AdminPage/OptionsPage.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Settings\AdminPage;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Settings\Forms;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
@@ -43,14 +44,14 @@ final class OptionsPage {
* @since 2.0.0
* @version 2.0.0
*
- * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the page's tabs filter.
+ * @param PluginIdentity $identity The plugin's identity, whose hook root starts the page's tabs filter.
* @param string $id The page's menu slug.
* @param OptionsPageDefinitionInterface $definition The page's definition.
*
* @throws InvalidArgumentException Thrown when the id is not a valid key.
*/
public function __construct(
- protected string $slug,
+ protected PluginIdentity $identity,
protected string $id,
protected OptionsPageDefinitionInterface $definition
) {
@@ -247,7 +248,7 @@ protected function get_page(): array {
* @param list $tabs The page's tabs.
* @param string $page_id The page's menu slug.
*/
- $page['tabs'] = apply_filters( \str_replace( '-', '_', $this->slug ) . '/options_page/tabs', $page['tabs'], $this->id );
+ $page['tabs'] = apply_filters( "{$this->identity->hook_root}/options_page/tabs", $page['tabs'], $this->id );
$this->page = $page;
}
diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php
index ae9eba5..6349d17 100644
--- a/packages/settings/src/EditScreen/ObjectForm.php
+++ b/packages/settings/src/EditScreen/ObjectForm.php
@@ -4,6 +4,7 @@
use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
use DeepWebSolutions\Framework\Core\AdminNotice\Notice;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Settings\Forms;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use DeepWebSolutions\Framework\Shared\Exception\LogicException;
@@ -64,7 +65,7 @@ final class ObjectForm {
* @since 2.0.0
* @version 2.0.0
*
- * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the form's fields filter.
+ * @param PluginIdentity $identity The plugin's identity, whose hook root starts the form's fields filter.
* @param string $id The form's id, which starts with the plugin's prefix and names the form's controls and nonce.
* @param ObjectFormDefinitionInterface $definition The form's definition.
* @param ObjectFormHostInterface $host The host that shows and saves the form.
@@ -74,7 +75,7 @@ final class ObjectForm {
* @throws InvalidArgumentException Thrown when the id is not a valid key.
*/
public function __construct(
- protected string $slug,
+ protected PluginIdentity $identity,
protected string $id,
protected ObjectFormDefinitionInterface $definition,
protected ObjectFormHostInterface $host,
@@ -265,7 +266,7 @@ protected function get_form( int $object_id ): array {
* @param int $object_id The id of the object being edited or saved.
* @param string $form_id The form's id.
*/
- $form['fields'] = apply_filters( \str_replace( '-', '_', $this->slug ) . '/object_form/fields', $form['fields'], $object_id, $this->id );
+ $form['fields'] = apply_filters( "{$this->identity->hook_root}/object_form/fields", $form['fields'], $object_id, $this->id );
$this->forms[ $object_id ] = $form;
}
diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md
index 98b96d4..ac4db99 100644
--- a/packages/woocommerce/README.md
+++ b/packages/woocommerce/README.md
@@ -92,10 +92,11 @@ require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/functions.php';
```
-The helpers need no kernel. On `plugins_loaded` at priority 15, after WooCommerce has loaded, the plugin checks WooCommerce against its floor, registers a product data tab and adds a settings tab in WooCommerce's filter, where `WC_Settings_Page` exists:
+The helpers need no kernel. On `plugins_loaded` at priority 15, after WooCommerce has loaded, the plugin builds its `PluginIdentity`, checks WooCommerce against the floor its entry file declares, registers a product data tab and adds a settings tab in WooCommerce's filter, where `WC_Settings_Page` exists:
```php
use Acme\PersonaWooCommerce\Packing;
+use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab;
use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab;
@@ -106,11 +107,12 @@ defined( 'ABSPATH' ) || exit;
add_action(
'plugins_loaded',
static function (): void {
- if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) {
+ $identity = new PluginIdentity( ACME_PW_FILE, Packing::SLUG, Packing::PREFIX );
+ if ( ! WooCommerceRequirement::check( $identity ) ) {
return;
}
- new ProductDataTab( Packing::SLUG, Packing::PREFIX, new Packing() )->register_hooks();
+ new ProductDataTab( $identity, Packing::PREFIX, new Packing() )->register_hooks();
add_filter(
'woocommerce_get_settings_pages',
static function ( array $pages ): array {
@@ -129,8 +131,8 @@ add_action(
);
add_action(
'woocommerce_update_options_' . Packing::PREFIX,
- static function (): void {
- new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' );
+ static function () use ( $identity ): void {
+ new WooCommerceLogger( $identity, 'info' )->info( 'The packing settings were saved.' );
}
);
},
@@ -189,7 +191,7 @@ The plugin's `uninstall.php` runs core's `Uninstaller` as core's README shows, a
```php
Uninstaller::run(
- $acme_pw_file,
+ $acme_pw_identity,
$acme_pw_footprint,
array(
'consent' => array( 'option' => $acme_pw_consent ),
diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php
index 0637843..dfa135a 100644
--- a/packages/woocommerce/src/Logging/WooCommerceLogger.php
+++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php
@@ -4,6 +4,7 @@
use Automattic\WooCommerce\Utilities\LoggingUtil;
use DeepWebSolutions\Framework\Core\Logging\AbstractLogger;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException;
use Psr\Log\LogLevel as PsrLogLevel;
@@ -38,14 +39,14 @@ final class WooCommerceLogger extends AbstractLogger {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $source The source that files every record under the plugin in WooCommerce's log.
+ * @param PluginIdentity $identity The identity of the plugin, whose slug is the source that files every record under it 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. 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.
*/
public function __construct(
- protected string $source,
+ protected PluginIdentity $identity,
?string $threshold = null,
protected ?\WC_Logger_Interface $logger = null
) {
@@ -101,7 +102,7 @@ public function log( mixed $level, string|\Stringable $message, array $context =
$message = self::interpolate( (string) $message, $context );
$this->logger ??= \function_exists( 'wc_get_logger' ) ? wc_get_logger() : null;
- $this->logger?->log( $level, $message, array( 'source' => $this->source ) + $encoded );
+ $this->logger?->log( $level, $message, array( 'source' => $this->identity->slug ) + $encoded );
if ( $severity < self::SEVERITIES[ PsrLogLevel::ERROR ] ) {
return;
@@ -109,7 +110,7 @@ public function log( mixed $level, string|\Stringable $message, array $context =
$dropped = ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity;
if ( $dropped ) {
- \error_log( self::format_record( $this->source, $level, $message, $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Errors must stay visible when WooCommerce drops them.
+ \error_log( self::format_record( $this->identity, $level, $message, $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Errors must stay visible when WooCommerce drops them.
}
}
diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php
index d037079..ecbd6ed 100644
--- a/packages/woocommerce/src/ProductData/ProductDataTab.php
+++ b/packages/woocommerce/src/ProductData/ProductDataTab.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\WooCommerce\ProductData;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
use DeepWebSolutions\Framework\Settings\Forms;
use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
@@ -42,14 +43,14 @@ final class ProductDataTab {
* @since 2.0.0
* @version 2.0.0
*
- * @param non-empty-string $slug The plugin's slug, whose dashes become underscores in the root of the tab's groups filter.
+ * @param PluginIdentity $identity The plugin's identity, whose hook root starts the tab's groups filter.
* @param string $id The tab's key among the product data tabs, which also prefixes its panel's HTML id.
* @param ProductDataTabDefinitionInterface $definition The definition, which the tab asks for once per product, on first use after init.
*
* @throws InvalidArgumentException Thrown when the id is not a valid key.
*/
public function __construct(
- protected string $slug,
+ protected PluginIdentity $identity,
protected string $id,
protected ProductDataTabDefinitionInterface $definition
) {
@@ -221,7 +222,7 @@ protected function get_groups( array $tab, \WC_Product $product ): array {
* @param \WC_Product $product The product being edited or saved.
* @param string $tab_id The tab's key among the product data tabs.
*/
- return apply_filters( \str_replace( '-', '_', $this->slug ) . '/product_data_tab/groups', $tab['groups'], $product, $this->id );
+ return apply_filters( "{$this->identity->hook_root}/product_data_tab/groups", $tab['groups'], $product, $this->id );
}
/**
diff --git a/packages/woocommerce/src/WooCommerceRequirement.php b/packages/woocommerce/src/WooCommerceRequirement.php
index 2480070..12db62b 100644
--- a/packages/woocommerce/src/WooCommerceRequirement.php
+++ b/packages/woocommerce/src/WooCommerceRequirement.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\WooCommerce;
use DeepWebSolutions\Framework\Bootstrap\Requirements;
+use DeepWebSolutions\Framework\Core\PluginIdentity;
\defined( 'ABSPATH' ) || exit;
@@ -35,21 +36,21 @@ protected function __construct() {}
* @since 2.0.0
* @version 2.0.0
*
- * @param string $plugin_file The path to the plugin's entry file.
+ * @param PluginIdentity $identity The plugin's identity, whose entry file holds the WC requires at least header.
*
* @return bool
*/
- public static function check( string $plugin_file ): bool {
- $floor = get_file_data( $plugin_file, array( 'floor' => 'WC requires at least' ) )['floor'];
+ public static function check( PluginIdentity $identity ): bool {
+ $floor = get_file_data( $identity->file, array( 'floor' => 'WC requires at least' ) )['floor'];
if ( \defined( 'WC_VERSION' ) && ( '' === $floor || \version_compare( WC_VERSION, $floor, '>=' ) ) ) {
return true;
}
add_action(
'all_admin_notices',
- static function () use ( $plugin_file, $floor ): void {
+ static function () use ( $identity, $floor ): void {
if ( ! \defined( 'WC_VERSION' ) ) {
- $requirements = validate_plugin_requirements( plugin_basename( $plugin_file ) ); // WordPress core names a missing WooCommerce only for a plugin whose Requires Plugins header lists it.
+ $requirements = validate_plugin_requirements( plugin_basename( $identity->file ) ); // WordPress core names a missing WooCommerce only for a plugin whose Requires Plugins header lists it.
if ( is_wp_error( $requirements ) ) {
Requirements::print_admin_notice( $requirements );
}
@@ -60,7 +61,7 @@ static function () use ( $plugin_file, $floor ): void {
\sprintf(
/* translators: 1: The plugin's name. 2: The lowest WooCommerce version the plugin runs on. */
esc_html__( '%1$s needs WooCommerce %2$s or later, so its features stay off until WooCommerce is updated.', 'wp-framework' ),
- esc_html( get_plugin_data( $plugin_file, false )['Name'] ),
+ esc_html( get_plugin_data( $identity->file, false )['Name'] ),
esc_html( $floor )
)
);
From 80df80f752ec6a8b0652e8ff3eba8df78273ca7b Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 23:18:59 +0100
Subject: [PATCH 22/23] Expect a stylesheet without an RTL file to keep its own
file
Assets set every stylesheet to be replaced by its -rtl.css on right-to-
left sites, so a stylesheet built without wp-scripts, which writes no
such file, made WordPress request a missing file there and the plugin's
screens lost their styles, where the rest of the best-effort fallback
only degrades.
The fixture build gains editor.css and its editor-rtl.css, as wp-scripts
writes them, and a row expects vendor.css, which has no RTL file, to
register without the swap.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Fixtures/Core/assets/build/editor-rtl.css | 1 +
tests/Fixtures/Core/assets/build/editor.css | 1 +
tests/Integration/Core/Asset/AssetsTest.php | 6 ++++++
3 files changed, 8 insertions(+)
create mode 100644 tests/Fixtures/Core/assets/build/editor-rtl.css
create mode 100644 tests/Fixtures/Core/assets/build/editor.css
diff --git a/tests/Fixtures/Core/assets/build/editor-rtl.css b/tests/Fixtures/Core/assets/build/editor-rtl.css
new file mode 100644
index 0000000..aa93931
--- /dev/null
+++ b/tests/Fixtures/Core/assets/build/editor-rtl.css
@@ -0,0 +1 @@
+.acme-editor { margin-right: 1em; }
diff --git a/tests/Fixtures/Core/assets/build/editor.css b/tests/Fixtures/Core/assets/build/editor.css
new file mode 100644
index 0000000..d0acd99
--- /dev/null
+++ b/tests/Fixtures/Core/assets/build/editor.css
@@ -0,0 +1 @@
+.acme-editor { margin-left: 1em; }
diff --git a/tests/Integration/Core/Asset/AssetsTest.php b/tests/Integration/Core/Asset/AssetsTest.php
index 6e8a7da..078332a 100644
--- a/tests/Integration/Core/Asset/AssetsTest.php
+++ b/tests/Integration/Core/Asset/AssetsTest.php
@@ -43,6 +43,12 @@ public function test_a_style_registers_with_its_build_s_version_and_a_right_to_l
self::assertSame( 'replace', $style->extra['rtl'] ?? null );
}
+ public function test_a_style_without_a_right_to_left_file_keeps_its_own_file_on_right_to_left_sites(): void {
+ self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/vendor.css' ) );
+
+ self::assertArrayNotHasKey( 'rtl', wp_styles()->registered[ self::STYLE ]->extra );
+ }
+
public function test_the_style_chunk_of_an_entry_takes_the_entry_s_version(): void {
self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/style-editor.css' ) );
From 38842139be1de10868065192d4c375817687eb6e Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Thu, 12 Nov 2026 23:21:17 +0100
Subject: [PATCH 23/23] Swap in a right-to-left stylesheet only where its file
exists
register_style() set every stylesheet to be replaced by its -rtl.css on
right-to-left sites, so a stylesheet built without wp-scripts, which has
no such file, loaded nothing there.
The swap is set only when the -rtl.css WordPress would request is
readable beside the stylesheet; one without it keeps its own file on
right-to-left sites, as the rest of the fallback's best effort does.
wp-scripts builds, which always write the variant, are unchanged. The
core README says so.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/README.md | 2 +-
packages/core/src/Asset/Assets.php | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/core/README.md b/packages/core/README.md
index e01023d..24c4266 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -192,7 +192,7 @@ final class Greeting implements ComponentInterface {
}
```
-`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $identity )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet its right-to-left variant. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported: a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version, and a script then with no dependencies, as a best effort.
+`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $identity )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet the right-to-left variant wp-scripts writes beside it. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported. As a best effort, a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version and, for a script, no dependencies, and a stylesheet without a `-rtl.css` beside it keeps its own file on right-to-left sites.
The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, builds the plugin's identity from the root's constants, and on a site below the floors or with an incomplete build it keeps the data:
diff --git a/packages/core/src/Asset/Assets.php b/packages/core/src/Asset/Assets.php
index 0119ec8..a795522 100644
--- a/packages/core/src/Asset/Assets.php
+++ b/packages/core/src/Asset/Assets.php
@@ -58,7 +58,7 @@ public function register_script( string $handle, string $path, array $args = arr
}
/**
- * Registers a built stylesheet with its build's version and its right-to-left variant, and returns whether WordPress registered it.
+ * Registers a built stylesheet with its build's version, and with the right-to-left variant wp-scripts writes beside it where that file exists, and returns whether WordPress registered it.
*
* @since 2.0.0
* @version 2.0.0
@@ -70,7 +70,7 @@ public function register_script( string $handle, string $path, array $args = arr
*/
public function register_style( string $handle, string $path ): bool {
$registered = wp_register_style( $handle, plugin_dir_url( $this->identity->file ) . $path, array(), $this->get_asset( $path )['version'] );
- if ( $registered ) {
+ if ( $registered && \is_readable( plugin_dir_path( $this->identity->file ) . \str_replace( '.css', '-rtl.css', $path ) ) ) {
wp_style_add_data( $handle, 'rtl', 'replace' );
}