From b5bb98a35f8cd363222cd50c0c7f59d265b0902f Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 13:51:23 +0100 Subject: [PATCH 1/4] Declare definitions without an unused container Convention 2 spelled every definition as static fn ( ContainerInterface $c ), so consumer-a, consumer-b and the core persona declared $c in six closures that never read it. Under the production PHPCS profile each one is a Generic.CodeAnalysis.UnusedFunctionParameter warning, which fails the run: the plugin template, built from consumer-a's anatomy, reported four of them, while the fixtures passed only because they sit under the tests profile, which excludes the sniff. PHP-DI calls a factory with the container whether or not it declares the parameter, and PHP ignores extra arguments to a closure. The six closures take no parameter, their docblocks return \Closure(): object, and core's README excerpt follows its persona. Convention 2 now reads that definitions are static closures, taking ContainerInterface $c only when they read the container, or autowiring. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/core/README.md | 3 +-- tests/Fixtures/consumer-a/src/Plugin.php | 11 +++++------ tests/Fixtures/consumer-b/src/Plugin.php | 5 ++--- tests/Fixtures/personas/core/src/Plugin.php | 5 ++--- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1517e58..0dc45ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ 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 fn ( ContainerInterface $c )` closures 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. +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 plugin 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. Relaxation happens only through the safeguard's own filter, after `{prefix}_initialized`, on an explicit `false`. diff --git a/packages/core/README.md b/packages/core/README.md index fa6e022..248eeee 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -122,7 +122,6 @@ use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use Acme\PersonaCore\Scoped\DI\ContainerBuilder; -use Acme\PersonaCore\Scoped\Psr\Container\ContainerInterface as PsrContainerInterface; use Acme\PersonaCore\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface; // ... @@ -165,7 +164,7 @@ final class Plugin { protected function get_definitions( PsrLoggerInterface $logger ): array { return array( - AdminNotices::class => static fn ( PsrContainerInterface $c ): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), ); } diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index d62679b..9790517 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -11,7 +11,6 @@ use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\WooCommerceRequirement; use Acme\ConsumerA\Scoped\DI\ContainerBuilder; -use Acme\ConsumerA\Scoped\Psr\Container\ContainerInterface as PsrContainerInterface; use Acme\ConsumerA\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface; \defined( 'ABSPATH' ) || exit; @@ -195,14 +194,14 @@ protected function compose(): bool { * @param PsrLoggerInterface $logger The plugin's logger. * @param Scheduler $scheduler The plugin's scheduler. * - * @return array + * @return array */ protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( - PsrLoggerInterface::class => static fn ( PsrContainerInterface $c ): PsrLoggerInterface => $logger, - Scheduler::class => static fn ( PsrContainerInterface $c ): Scheduler => $scheduler, - SchedulerInterface::class => static fn ( PsrContainerInterface $c ): Scheduler => $scheduler, - AdminNotices::class => static fn ( PsrContainerInterface $c ): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + 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 ), ); } diff --git a/tests/Fixtures/consumer-b/src/Plugin.php b/tests/Fixtures/consumer-b/src/Plugin.php index 59abb78..7fe19ad 100644 --- a/tests/Fixtures/consumer-b/src/Plugin.php +++ b/tests/Fixtures/consumer-b/src/Plugin.php @@ -7,7 +7,6 @@ use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use Acme\ConsumerB\Scoped\DI\ContainerBuilder; -use Acme\ConsumerB\Scoped\Psr\Container\ContainerInterface as PsrContainerInterface; use Acme\ConsumerB\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface; \defined( 'ABSPATH' ) || exit; @@ -142,11 +141,11 @@ protected function compose(): bool { * * @param PsrLoggerInterface $logger The plugin's logger. * - * @return array + * @return array */ protected function get_definitions( PsrLoggerInterface $logger ): array { return array( - AdminNotices::class => static fn ( PsrContainerInterface $c ): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), ); } diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php index 20f40c4..ad143ee 100644 --- a/tests/Fixtures/personas/core/src/Plugin.php +++ b/tests/Fixtures/personas/core/src/Plugin.php @@ -9,7 +9,6 @@ use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use Acme\PersonaCore\Scoped\DI\ContainerBuilder; -use Acme\PersonaCore\Scoped\Psr\Container\ContainerInterface as PsrContainerInterface; use Acme\PersonaCore\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface; \defined( 'ABSPATH' ) || exit; @@ -158,11 +157,11 @@ protected function compose(): bool { * * @param PsrLoggerInterface $logger The plugin's logger. * - * @return array + * @return array */ protected function get_definitions( PsrLoggerInterface $logger ): array { return array( - AdminNotices::class => static fn ( PsrContainerInterface $c ): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), ); } From 551bb1f33e7efd903245ab56ec6f29fecd9f4c0a Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 14:02:12 +0100 Subject: [PATCH 2/4] Hold consumer-a's footprint in its own class consumer-a declared its footprint as Plugin::FOOTPRINT, which put uninstall data in the composition root, and its one option prefix missed part of what its own roots store: the transients PluginBoot records boot failures in, AdminNotices' user options and network-wide user meta, and, on a network, its network notices option. A consenting uninstall therefore kept those rows, and the plugin template, which takes consumer-a's anatomy, would have copied the gap. The footprint is now Footprint::DATA in src/Footprint.php, beside Migrations::STEPS, and covers those kinds through the transient variants of the prefix, the site and global user meta prefixes and the network notices option. The consenting uninstall row seeds and checks one row of each, and the rows for an engine that cannot run gain a build without src/Footprint.php. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/consumer-a/src/Footprint.php | 47 +++++++++++++++++++ tests/Fixtures/consumer-a/src/Plugin.php | 15 ------ tests/Fixtures/consumer-a/uninstall.php | 3 +- .../ConsumerA/ConsumerAUninstallTest.php | 14 ++++++ 4 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 tests/Fixtures/consumer-a/src/Footprint.php diff --git a/tests/Fixtures/consumer-a/src/Footprint.php b/tests/Fixtures/consumer-a/src/Footprint.php new file mode 100644 index 0000000..f3bf73e --- /dev/null +++ b/tests/Fixtures/consumer-a/src/Footprint.php @@ -0,0 +1,47 @@ +, 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 DATA = 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( Plugin::PREFIX . '_revision_run' ), + 'as_groups' => array( Plugin::PREFIX ), + ); + + // 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 9790517..12095a7 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -61,21 +61,6 @@ final class Plugin { FaultInjector::class, ); - /** - * The data the plugin stores, which uninstall removes where the site consents. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var array{option_prefixes: list, meta: array{post: list}, cron_hooks: list, as_groups: list} - */ - public const array FOOTPRINT = array( - 'option_prefixes' => array( self::PREFIX . '_' ), - 'meta' => array( 'post' => array( ProductData::NOTE_KEY ) ), - 'cron_hooks' => array( self::PREFIX . '_revision_run' ), - 'as_groups' => array( self::PREFIX ), - ); - /** * The boot boundary, whose status tells whether the plugin booted. * diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index 2a7f2dd..0fc5a6b 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\Footprint; use Acme\ConsumerA\Plugin; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; @@ -28,7 +29,7 @@ } require_once __DIR__ . '/vendor/autoload.php'; - $acme_ca_footprint = Plugin::FOOTPRINT; + $acme_ca_footprint = Footprint::DATA; $acme_ca_meta_types = MetaTypes::DEFINITIONS; } 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. diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index 5647e83..17242f9 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -18,6 +18,8 @@ final class ConsumerAUninstallTest extends TestCase { protected int $product_id = 0; + protected int $user_id = 0; + protected string $log = ''; public static function engines_that_cannot_run(): array { @@ -26,6 +28,7 @@ public static function engines_that_cannot_run(): array { 'autoloader' => array( array( 'vendor/autoload.php' ), array() ), 'scoped-autoloader' => array( array( 'vendor-prefixed/scoper-autoload.php' ), array() ), 'composition-root' => array( array( 'src/Plugin.php' ), array() ), + 'footprint' => array( array( 'src/Footprint.php' ), array() ), 'below-php-floor' => array( array(), array( 'Requires PHP' => '99' ) ), ); } @@ -40,6 +43,11 @@ public function test_a_consenting_web_uninstall_removes_the_footprint_and_the_fi self::assertFalse( get_option( 'acme_ca_settings' ) ); self::assertFalse( get_option( 'acme_ca_revision' ) ); self::assertSame( '', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); + self::assertFalse( get_option( 'acme_ca_notices' ) ); + self::assertFalse( get_network_option( null, 'acme_ca_network_notices' ) ); + self::assertFalse( get_transient( 'acme_ca_boot_failure_seeded' ) ); + self::assertFalse( get_user_option( 'acme_ca_hidden_notices', $this->user_id ) ); + self::assertSame( '', get_user_meta( $this->user_id, 'acme_ca_network_hidden_notices', true ) ); } #[DataProvider( 'engines_that_cannot_run' )] @@ -91,6 +99,12 @@ protected function delete_seeded_rows(): void { protected function seed_footprint(): void { update_option( 'acme_ca_settings', array( 'delete_data' => 'yes' ) ); update_option( 'acme_ca_revision', 1 ); + update_option( 'acme_ca_notices', array( 'seeded' ) ); + update_network_option( null, 'acme_ca_network_notices', array( 'seeded' ) ); + set_transient( 'acme_ca_boot_failure_seeded', 1, HOUR_IN_SECONDS ); + $this->user_id = $this->make_user( 'acme_ca_admin', 'administrator' ); + update_user_option( $this->user_id, 'acme_ca_hidden_notices', array( 'seeded' ) ); + update_user_meta( $this->user_id, 'acme_ca_network_hidden_notices', array( 'seeded' ) ); $product = wc_get_product_object( 'simple' ); $product->set_name( 'Acme product' ); From 564bb869bfc0901753b85535786bc264e4ad2942 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 13:51:31 +0100 Subject: [PATCH 3/4] Load consumer-a's includes the way the template does The plugin template's functions.php requires every includes/*.php in name order and skips names that start with an underscore, so a plugin can park a file there without loading it; consumer-a, the anatomy the template follows, loaded only safeguards.php, from its entry. consumer-a's functions.php now runs the same loader, throwing when the directory cannot be read so the entry's build-error handling reports it, and ships includes/_unloaded.php, whose function the boot row asserts is never defined. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/consumer-a/functions.php | 14 ++++++++++- .../consumer-a/includes/_unloaded.php | 23 +++++++++++++++++++ .../ConsumerA/ConsumerABootTest.php | 1 + .../Integration/Support/consumer-request.php | 1 + 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/consumer-a/includes/_unloaded.php diff --git a/tests/Fixtures/consumer-a/functions.php b/tests/Fixtures/consumer-a/functions.php index 076ad0f..aa503d5 100644 --- a/tests/Fixtures/consumer-a/functions.php +++ b/tests/Fixtures/consumer-a/functions.php @@ -1,6 +1,6 @@ has_filter( 'update_plugins_github.com' ), 'schedules_hooked' => has_filter( 'cron_schedules' ), 'safeguards_loaded' => function_exists( 'acme_ca_is_relaxed' ), + 'unloaded_include' => function_exists( 'acme_ca_unloaded_include' ), 'greeting_shortcode' => shortcode_exists( 'acme_pc_greeting' ), ); From 33f90f9cb3efa9631f9d675c12449af8e4a92765 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 13:56:49 +0100 Subject: [PATCH 4/4] Limit consumer-a's footprint docblock to the plugin's prefix The docblock said the footprint covers every transient the plugin and its framework components write, but the bootstrap updater caches its releases under the text domain, in {slug}_github_release_{channel}, which expires on its own and which the footprint leaves out on purpose. The docblock now names what the footprint covers: the plugin's data under its prefix. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Fixtures/consumer-a/src/Footprint.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fixtures/consumer-a/src/Footprint.php b/tests/Fixtures/consumer-a/src/Footprint.php index f3bf73e..f0289cf 100644 --- a/tests/Fixtures/consumer-a/src/Footprint.php +++ b/tests/Fixtures/consumer-a/src/Footprint.php @@ -14,7 +14,7 @@ final class Footprint { // region FIELDS AND CONSTANTS /** - * The plugin's stored data by kind, which covers every option, transient, meta key and scheduled job the plugin and its framework components write. + * 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