Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, E>` 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).

Expand Down
34 changes: 34 additions & 0 deletions tests/Fixtures/consumer-a/includes/footprint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php declare( strict_types=1 );
/**
* Declares the data the plugin stores, which uninstall removes where the site consents.
*
* @since 1.0.0
* @version 1.0.0
*
* @package Acme\ConsumerA
*/

use Acme\ConsumerA\Plugin;
use Acme\ConsumerA\ProductData;

defined( 'ABSPATH' ) || exit;

/**
* Returns 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
*
* @return array{option_prefixes: list<non-empty-string>, meta: array{post: list<non-empty-string>}, user_meta_site_prefixes: list<non-empty-string>, user_meta_global_prefixes: list<non-empty-string>, network_options: list<non-empty-string>, cron_hooks: list<non-empty-string>, as_groups: list<non-empty-string>}
*/
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 ),
);
}
43 changes: 43 additions & 0 deletions tests/Fixtures/consumer-a/includes/migrations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php declare( strict_types=1 );
/**
* Holds the plugin's migration steps, which bring its stored data to the revision its code expects.
*
* @since 1.0.0
* @version 1.0.0
*
* @package Acme\ConsumerA
*/

use Acme\ConsumerA\Plugin;

defined( 'ABSPATH' ) || exit;

/**
* Returns the migration steps, append-only, where index 0 brings the data to revision 1.
*
* @since 1.0.0
* @version 1.0.0
*
* @return list<callable(): void>
*/
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.' );
}
}
47 changes: 0 additions & 47 deletions tests/Fixtures/consumer-a/src/Footprint.php

This file was deleted.

61 changes: 0 additions & 61 deletions tests/Fixtures/consumer-a/src/Migrations.php

This file was deleted.

2 changes: 1 addition & 1 deletion tests/Fixtures/consumer-a/src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
Expand Down
4 changes: 2 additions & 2 deletions tests/Fixtures/consumer-a/uninstall.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tests/Integration/ConsumerA/ConsumerAUninstallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) ),
);
}
Expand Down
Loading