From 88ad6574bdea56086190032088da877d968e97cb Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 16:09:29 +0100 Subject: [PATCH 1/2] Move consumer-a's footprint and migrations into includes consumer-a kept its footprint and migration steps as final classes in src/, beside its composition root and components, though neither registers a hook or joins the component tree. The template now keeps src/ for that tree alone and puts such code in includes/, and consumer-a is the anatomy the template adopts, so the two would drift apart. Footprint and Migrations become includes/footprint.php and includes/migrations.php, a typed footprint function and the step list with its step, which the includes loader requires. uninstall.php requires the footprint by path, and the uninstall row that removes the footprint now removes that file. Both files parse on PHP 7.4, which the consumer-a syntax job checks. Assisted-by: Claude Code:claude-opus-5-5 --- .../consumer-a/includes/footprint.php | 34 +++++++++++ .../consumer-a/includes/migrations.php | 43 +++++++++++++ tests/Fixtures/consumer-a/src/Footprint.php | 47 -------------- tests/Fixtures/consumer-a/src/Migrations.php | 61 ------------------- tests/Fixtures/consumer-a/src/Plugin.php | 2 +- tests/Fixtures/consumer-a/uninstall.php | 4 +- .../ConsumerA/ConsumerAUninstallTest.php | 2 +- 7 files changed, 81 insertions(+), 112 deletions(-) create mode 100644 tests/Fixtures/consumer-a/includes/footprint.php create mode 100644 tests/Fixtures/consumer-a/includes/migrations.php delete mode 100644 tests/Fixtures/consumer-a/src/Footprint.php delete mode 100644 tests/Fixtures/consumer-a/src/Migrations.php diff --git a/tests/Fixtures/consumer-a/includes/footprint.php b/tests/Fixtures/consumer-a/includes/footprint.php new file mode 100644 index 0000000..a589f03 --- /dev/null +++ b/tests/Fixtures/consumer-a/includes/footprint.php @@ -0,0 +1,34 @@ +, 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( Plugin::PREFIX . '_revision_run' ), + 'as_groups' => array( Plugin::PREFIX ), + ); +} diff --git a/tests/Fixtures/consumer-a/includes/migrations.php b/tests/Fixtures/consumer-a/includes/migrations.php new file mode 100644 index 0000000..1a12495 --- /dev/null +++ b/tests/Fixtures/consumer-a/includes/migrations.php @@ -0,0 +1,43 @@ + + */ +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/src/Footprint.php b/tests/Fixtures/consumer-a/src/Footprint.php deleted file mode 100644 index f0289cf..0000000 --- a/tests/Fixtures/consumer-a/src/Footprint.php +++ /dev/null @@ -1,47 +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} - */ - 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/Migrations.php b/tests/Fixtures/consumer-a/src/Migrations.php deleted file mode 100644 index e98fcb6..0000000 --- a/tests/Fixtures/consumer-a/src/Migrations.php +++ /dev/null @@ -1,61 +0,0 @@ - - */ - public const array STEPS = array( - array( self::class, 'add_settings' ), - ); - - // endregion - - // region MAGIC METHODS - - /** - * Constructor. - * - * @since 1.0.0 - * @version 1.0.0 - */ - protected function __construct() {} - - // endregion - - // region METHODS - - /** - * 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 - */ - public static function 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.' ); - } - } - - // endregion -} diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index 12095a7..f434539 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -161,7 +161,7 @@ protected function compose(): bool { return false; } - $this->migrations = new MigrationRunner( ACME_CA_FILE, self::PREFIX . '_revision', Migrations::STEPS, $this->logger, $this->scheduler ); + $this->migrations = new MigrationRunner( ACME_CA_FILE, 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() ); diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index 0fc5a6b..08240ab 100644 --- a/tests/Fixtures/consumer-a/uninstall.php +++ b/tests/Fixtures/consumer-a/uninstall.php @@ -8,7 +8,6 @@ * @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; @@ -29,7 +28,8 @@ } require_once __DIR__ . '/vendor/autoload.php'; - $acme_ca_footprint = Footprint::DATA; + require_once __DIR__ . '/includes/footprint.php'; + $acme_ca_footprint = acme_ca_get_footprint(); $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 17242f9..ed8c041 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -28,7 +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() ), + 'footprint' => array( array( 'includes/footprint.php' ), array() ), 'below-php-floor' => array( array(), array( 'Requires PHP' => '99' ) ), ); } From 36aee3217ff2ca454a66044b616d9bbd40b0d003 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 16:09:29 +0100 Subject: [PATCH 2/2] Place a plugin's hook-free code in includes Convention 6 put the logic that global functions or templates need in static methods of final classes, and convention 8 named an Options class and its text() method, while the template and consumer-a now keep src/ for the composition root and its components and put option keys and readers, the footprint, migration steps and asset registrars in includes/. Convention 6 states that layout, and convention 8 names the plugin's prefixed option constants and its text reader. The rule that packages ship no free functions is unchanged. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0dc45ea..e1008ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,9 +73,9 @@ 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 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`. -6. **Shared logic.** Logic that global functions or templates need lives in static methods of final classes. Per-request state lives in a static holder or `WC()->session`. No service locator. +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 `Options` constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from `Options::text()` 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 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. 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 (its own `PluginCheck` sniffs and file checks run only at release).