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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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`.
Expand Down
3 changes: 1 addition & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// ...
Expand Down Expand Up @@ -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 ),
);
}

Expand Down
14 changes: 13 additions & 1 deletion tests/Fixtures/consumer-a/functions.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php declare( strict_types=1 );
/**
* Defines the plugin's accessor, boots it on plugins_loaded and declares its WooCommerce feature compatibility.
* Defines the plugin's accessor, boots it on plugins_loaded, declares its WooCommerce feature compatibility and loads the files in includes/.
*
* @since 1.0.0
* @version 1.0.0
Expand All @@ -16,6 +16,18 @@
add_action( 'plugins_loaded', 'acme_ca_boot', 15 );
add_action( 'before_woocommerce_init', 'acme_ca_declare_compatibility' );

$acme_ca_includes = glob( __DIR__ . '/includes/*.php' );
if ( false === $acme_ca_includes ) {
throw new RuntimeException( 'The includes directory could not be read.' );
}

sort( $acme_ca_includes );
foreach ( $acme_ca_includes as $acme_ca_include ) {
if ( ! str_starts_with( basename( $acme_ca_include ), '_' ) ) { // An underscore prefix keeps a file from loading.
require_once $acme_ca_include;
}
}

/**
* Returns the plugin's composition root, built on first use.
*
Expand Down
23 changes: 23 additions & 0 deletions tests/Fixtures/consumer-a/includes/_unloaded.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php declare( strict_types=1 );
/**
* Stays unloaded, because functions.php skips the files in includes/ whose names start with an underscore.
*
* @since 1.0.0
* @version 1.0.0
*
* @package Acme\ConsumerA
*/

defined( 'ABSPATH' ) || exit;

/**
* Returns true, so a request can tell whether this file loaded.
*
* @since 1.0.0
* @version 1.0.0
*
* @return bool
*/
function acme_ca_unloaded_include(): bool {
return true;
}
47 changes: 47 additions & 0 deletions tests/Fixtures/consumer-a/src/Footprint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php declare( strict_types=1 );

namespace Acme\ConsumerA;

\defined( 'ABSPATH' ) || exit;

/**
* Holds the data the plugin stores, which uninstall removes where the site consents.
*
* @since 1.0.0
* @version 1.0.0
*/
final class Footprint {
// region FIELDS AND CONSTANTS

/**
* 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<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>}
*/
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
}
26 changes: 5 additions & 21 deletions tests/Fixtures/consumer-a/src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,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<non-empty-string>, meta: array{post: list<non-empty-string>}, cron_hooks: list<non-empty-string>, as_groups: list<non-empty-string>}
*/
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.
*
Expand Down Expand Up @@ -195,14 +179,14 @@ protected function compose(): bool {
* @param PsrLoggerInterface $logger The plugin's logger.
* @param Scheduler $scheduler The plugin's scheduler.
*
* @return array<string, \Closure(PsrContainerInterface): object>
* @return array<string, \Closure(): object>
*/
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 ),
);
}

Expand Down
3 changes: 2 additions & 1 deletion tests/Fixtures/consumer-a/uninstall.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions tests/Fixtures/consumer-b/src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,11 +141,11 @@ protected function compose(): bool {
*
* @param PsrLoggerInterface $logger The plugin's logger.
*
* @return array<string, \Closure(PsrContainerInterface): object>
* @return array<string, \Closure(): object>
*/
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 ),
);
}

Expand Down
5 changes: 2 additions & 3 deletions tests/Fixtures/personas/core/src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,11 +157,11 @@ protected function compose(): bool {
*
* @param PsrLoggerInterface $logger The plugin's logger.
*
* @return array<string, \Closure(PsrContainerInterface): object>
* @return array<string, \Closure(): object>
*/
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 ),
);
}

Expand Down
1 change: 1 addition & 0 deletions tests/Integration/ConsumerA/ConsumerABootTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public function test_the_plugin_boots_with_its_scheduler_and_admin_notices_as_ke
self::assertSame( 1, $request['records']['consumer_a']['initialized'] );
self::assertSame( array( self::SCOPED . 'Core\\Scheduling\\Scheduler', self::SCOPED . 'Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerA\\ProductData', 'Acme\\ConsumerA\\Exports' ), $request['records']['consumer_a']['resolved'] );
self::assertTrue( $request['records']['export_hooked'] );
self::assertFalse( $request['records']['unloaded_include'] );
}

public function test_a_gated_subtree_is_skipped_and_its_children_never_load(): void {
Expand Down
14 changes: 14 additions & 0 deletions tests/Integration/ConsumerA/ConsumerAUninstallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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' ) ),
);
}
Expand All @@ -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' )]
Expand Down Expand Up @@ -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' );
Expand Down
1 change: 1 addition & 0 deletions tests/Integration/Support/consumer-request.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ static function () use ( $spec, $acme_capability, $acme_record_boots ): void {
'updater_hooked' => 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' ),
);

Expand Down
Loading