From c3d336280fd75fbfded58afd245240c0ab9f69f9 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 24 Aug 2026 14:00:04 +0200 Subject: [PATCH 1/4] Announce what the load pass loaded and what it skipped The error action tells a host what went wrong. It says nothing about the pass that went right, so there was still no way to run code when a sub-plugin loads short of hooking plugins_loaded at priority 7 and re-deriving the answer with defined() -- and no way at all to find out that a sub-plugin the host ships was turned away by a gate, since a skip is not a failure and never reported anything. Two more actions, named through Config::get_hook_name() like the first: loaded( Sub_Plugin ) and skipped( Sub_Plugin, string $reason ). loaded fires behind the activation callback. A listener is host code that reaches into the sub-plugin it was just told about, and on a first-ever load the tables and options that code expects are the callback's work -- and from in front of it, a throwing listener would skip the callback entirely and leave it to be retried, silently, on every request for ever. The five skip reasons are public const on Loader. Each names a gate in load() and nothing outside the load pass decides one, where Conflict_Policy earns a class of its own by carrying behaviour and by being read by the value object, the resolver and the host alike. Both run host code inside the per-sub-plugin try that has always guarded the should_load filter and the require itself, so a listener that throws costs its own sub-plugin and nothing behind it. The file gate announces down both channels. A bundled file that is not there is a build the host has to fix, which is what error carries, and a sub-plugin that is not going to be present, which is what anything watching skipped is counting. A host listening to both sees it twice, and docs/actions.md says so plainly rather than picking one. --- README.md | 2 +- docs/actions.md | 69 ++++++++++-- src/Loader.php | 107 +++++++++++++++++- tests/unit/LoaderTest.php | 229 +++++++++++++++++++++++++++++++++++++- 4 files changed, 386 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2ad38a3..81d3f96 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ two sub-plugins, every optional key. releases. - [Conflict handling][conflicts] — the policies, when they run, and the guard's limits. - [Filters][filters] — the runtime overrides for policies and notice text. -- [Actions][actions] — the failures the library announces, and what each one carries. +- [Actions][actions] — what the library reports as it loads, skips and fails. - [Notices][notices] — where the queue lives, who may see it, and how to render it yourself. - [Extending][extending] — swapping out a piece of the library. - [Tests][tests] — running the suite, the fixtures and traits it offers, and every scenario it drives diff --git a/docs/actions.md b/docs/actions.md index 8e01318..302c178 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -5,10 +5,53 @@ you override. `{prefix}` is the value passed to `Config::set_hook_prefix()`. | Action | Arguments | Fires when | |---|---|---| +| `{prefix}/plugin_absorber/loaded` | `Sub_Plugin $sub_plugin` | A bundled file was required, and its activation callback has already run. | +| `{prefix}/plugin_absorber/skipped` | `Sub_Plugin $sub_plugin`, `string $reason` | A gate turned a sub-plugin away. | | `{prefix}/plugin_absorber/error` | `string $message`, `Sub_Plugin\|null $sub_plugin` | Something went wrong that a developer has to fix. | -Everything announced here also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on. -This is the channel that is not — reach for it for a log line, a health check, or a support tool. +Every one of them also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on. These +are the channel that is not — reach for them for a log line, a health check, or a support tool. + +## Loading + +`loaded` is the answer to "is this sub-plugin here?" without a `defined()` check of your own, and it +is where code that builds on a sub-plugin belongs: + +```php +add_action( 'give/plugin_absorber/loaded', function ( $sub_plugin ) { + if ( $sub_plugin->get_slug() === 'give-recurring' ) { + My_Recurring_Bridge::init(); + } +} ); +``` + +It fires **after** the activation callback, so on a first-ever load the tables and options that +callback creates are already there. + +## Skip reasons + +The second argument is one of five values. Compare against the constant, not the string: + +| Constant | Value | Meaning | +|---|---|---| +| `Loader::SKIPPED_DISABLED` | `disabled` | The `enabled` key, or the callable behind it, said no. | +| `Loader::SKIPPED_ALREADY_LOADED` | `already_loaded` | The guard constant was already defined — usually a standalone copy that loaded first. | +| `Loader::SKIPPED_DEPENDENCIES_UNMET` | `dependencies_unmet` | `dependency_check` said the requirements are not met. The one skip that also queues a [notice](notices.md). | +| `Loader::SKIPPED_FILE_UNREADABLE` | `file_unreadable` | `bundled_plugin_file` does not name a readable file. | +| `Loader::SKIPPED_FILTERED` | `filtered` | The [`should_load` filter](filters.md#the-load-gate) returned something falsy. | + +```php +use Nexcess\PluginAbsorber\Loader; + +add_action( 'give/plugin_absorber/skipped', function ( $sub_plugin, $reason ) { + if ( $reason === Loader::SKIPPED_ALREADY_LOADED ) { + my_log( sprintf( '%s deferred to a standalone copy.', $sub_plugin->get_slug() ) ); + } +}, 10, 2 ); +``` + +The values are fixed API and will not change. New reasons may be added, so treat one you do not +recognise as a plain skip rather than as an error. ## Errors @@ -24,14 +67,20 @@ add_action( 'give/plugin_absorber/error', function ( $message, $sub_plugin ) { }, 10, 2 ); ``` -**A bootstrap with no hook prefix cannot be announced.** The prefix is what names this action, so -the one failure `error` can never carry is a missing `Config::set_hook_prefix()`. That one goes to -`_doing_it_wrong()` alone. +Two things to know: + +- **`file_unreadable` fires both.** A bundled file that is not there is a build to fix *and* a + sub-plugin that will not be present, so it is announced as an `error` and as a `skipped`. Listen + to both and you will see it twice. +- **A bootstrap with no hook prefix cannot be announced.** The prefix is what names these hooks, so + the one failure `error` can never carry is a missing `Config::set_hook_prefix()`. That one goes to + `_doing_it_wrong()` alone. ## Your listener cannot take the site down -`error` fires from inside `plugins_loaded`, and from inside the handlers that keep a failing -sub-plugin from white-screening the site, so a listener that throws is caught rather than allowed -out. A throw from it costs nothing at all and is itself reported through `_doing_it_wrong()`. That -is a backstop, not a licence — a listener here runs on every request the site serves, so keep it -cheap and keep it quiet. +These fire from inside `plugins_loaded` and from inside the handlers that keep a failing sub-plugin +from white-screening the site, so a listener that throws is caught rather than allowed out. A throw +from `loaded` or `skipped` costs that sub-plugin the rest of its load pass and nothing behind it; a +throw from `error` costs nothing at all and is itself reported through `_doing_it_wrong()`. That is +a backstop, not a licence — a listener here runs on every request the site serves, so keep it cheap +and keep it quiet. diff --git a/src/Loader.php b/src/Loader.php index 625ca11..71073de 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -28,6 +28,62 @@ class Loader { use Guards_Hook_Prefix; use Reports_Errors; + /** + * The `enabled` key, or the callable behind it, said no. + * + * This and the four that follow are the values the `skipped` action's second argument takes, and + * they are public API from the moment they ship: a host comparing against + * `Loader::SKIPPED_DISABLED` rather than against `'disabled'` is the point of naming them at all. + * They sit on this class because each one names a gate in `load()` and nothing outside the load + * pass decides one — where `Conflict_Policy` earns a class of its own by carrying behaviour + * (`default()`, `is_valid()`) and by being read by the value object, the resolver and the host + * alike. + * + * @since 1.0.0 + * + * @var string + */ + public const SKIPPED_DISABLED = 'disabled'; + + /** + * The guard constant was already defined, so the code is in memory — a standalone copy loaded + * ahead of this pass, or a second registration of the same plugin. + * + * @since 1.0.0 + * + * @var string + */ + public const SKIPPED_ALREADY_LOADED = 'already_loaded'; + + /** + * The `dependency_check` callable said the requirements are not met. The one skip that also + * queues a notice for the site owner. + * + * @since 1.0.0 + * + * @var string + */ + public const SKIPPED_DEPENDENCIES_UNMET = 'dependencies_unmet'; + + /** + * `bundled_plugin_file` does not name a readable file. A broken build in the host plugin, so it + * is announced through `error` as well as here. + * + * @since 1.0.0 + * + * @var string + */ + public const SKIPPED_FILE_UNREADABLE = 'file_unreadable'; + + /** + * The `should_load` filter returned something falsy. + * + * @since 1.0.0 + * + * @var string + */ + public const SKIPPED_FILTERED = 'filtered'; + /** * @since 1.0.0 * @@ -97,9 +153,10 @@ public function load_all(): void { // A re-declaration is the one failure this cannot catch, because PHP does not raise it as // a Throwable -- which is what the guard constant, checked before any of this, is for. // - // Reporting the failure cannot add one of its own: report_error() swallows whatever a - // listener on the error action throws, so the announcement of a sub-plugin that died - // cannot be what kills the request. + // The loaded and skipped actions run host code inside this same try, exactly as the + // should_load filter already does, so a listener that throws costs its own sub-plugin and + // nothing behind it. Reporting the failure cannot add one of its own: report_error() + // swallows whatever a listener on the error action throws. try { $this->load( $sub_plugin ); } catch ( Throwable $thrown ) { @@ -129,6 +186,8 @@ public function load_all(): void { */ private function load( Sub_Plugin $sub_plugin ): void { if ( ! $sub_plugin->is_enabled() ) { + $this->announce_skip( $sub_plugin, self::SKIPPED_DISABLED ); + return; } @@ -137,11 +196,14 @@ private function load( Sub_Plugin $sub_plugin ): void { // means "the plugin is already running" -- warning that requirements are unmet for a // plugin the admin can see working would be worse than useless. if ( $sub_plugin->is_already_loaded() ) { + $this->announce_skip( $sub_plugin, self::SKIPPED_ALREADY_LOADED ); + return; } if ( ! $sub_plugin->are_dependencies_met() ) { $this->notices->queue_dependency_notice( $sub_plugin ); + $this->announce_skip( $sub_plugin, self::SKIPPED_DEPENDENCIES_UNMET ); return; } @@ -154,9 +216,10 @@ private function load( Sub_Plugin $sub_plugin ): void { $file = $sub_plugin->get_bundled_plugin_file(); if ( ! is_file( $file ) || ! is_readable( $file ) ) { - // The sub-plugin travels with the sentence, because this failure belongs to exactly one - // registration: a listener told only that a bundled file is missing would have to parse - // the path back out to know which of them to act on. + // Both channels, because this gate is two things at once: a build the host has to fix, + // which is what `error` carries, and a sub-plugin that is not going to be there, which is + // what anything watching `skipped` is counting. A host listening to both sees this one + // twice, and that is said plainly in the docs rather than solved by picking one. self::report_error( self::class, sprintf( @@ -166,6 +229,7 @@ private function load( Sub_Plugin $sub_plugin ): void { ), $sub_plugin ); + $this->announce_skip( $sub_plugin, self::SKIPPED_FILE_UNREADABLE ); return; } @@ -176,6 +240,8 @@ private function load( Sub_Plugin $sub_plugin ): void { $should_load = apply_filters( Config::get_hook_name( 'should_load' ), true, $sub_plugin ); if ( ! $should_load ) { + $this->announce_skip( $sub_plugin, self::SKIPPED_FILTERED ); + return; } @@ -191,5 +257,34 @@ private function load( Sub_Plugin $sub_plugin ): void { // for a sub-plugin that was skipped would be worse: the schema would appear for a plugin // that is not loaded. $this->activator->maybe_run( $sub_plugin ); + + // Behind the activation callback, not in front of it. A listener here is host code that will + // reach into the sub-plugin it was just told about, and on the first-ever load the tables and + // options that code expects are the activation callback's work -- so announcing first would + // hand a host a plugin that is loaded but not yet set up. It also keeps a throwing listener + // off the callback: from here the throw is caught a frame up with the require and the + // activation already done, where from in front of it the throw would skip the callback + // entirely and leave it to be retried, silently, on every request for ever. + do_action( Config::get_hook_name( 'loaded' ), $sub_plugin ); + } + + /** + * Say that a gate turned this sub-plugin away, and which gate it was. + * + * The hook name is built in one place for the reason `Config` gives for owning the prefix at all, + * and the call is guarded by nothing: it runs inside the per-sub-plugin `catch` in `load_all()`, + * which is the same guard the `should_load` filter beside it has always had. + * + * @since 1.0.0 + * + * @param Sub_Plugin $sub_plugin Sub-plugin that was skipped. + * @param string $reason One of this class's `SKIPPED_*` constants. + * + * @throws Config_Exception When no hook prefix has been set. + * + * @return void + */ + private function announce_skip( Sub_Plugin $sub_plugin, string $reason ): void { + do_action( Config::get_hook_name( 'skipped' ), $sub_plugin, $reason ); } } diff --git a/tests/unit/LoaderTest.php b/tests/unit/LoaderTest.php index 5144566..1f9733b 100644 --- a/tests/unit/LoaderTest.php +++ b/tests/unit/LoaderTest.php @@ -8,6 +8,7 @@ namespace Nexcess\PluginAbsorber\Tests\Unit; use Codeception\TestCase\WPTestCase; +use Generator; use lucatume\WPBrowser\Traits\UopzFunctions; use Nexcess\PluginAbsorber\Absorber; use Nexcess\PluginAbsorber\Config; @@ -64,6 +65,24 @@ class LoaderTest extends WPTestCase { */ private $should_load_calls = []; + /** + * Every sub-plugin the `loaded` action was fired with, in order. + * + * @var array + */ + private $loaded_calls = []; + + /** + * Every `skipped` firing, as a [ slug, reason ] pair. + * + * The reason travels with the slug rather than in a list of its own, because a pass over two + * sub-plugins is the case where "one skip, for this reason" and "two skips, one of them for this + * reason" have to be told apart. + * + * @var array + */ + private $skipped_calls = []; + /** * Every `error` firing, as the message and whatever sub-plugin came with it. * @@ -82,6 +101,8 @@ public function setUp(): void { $this->clear_activations(); $this->reset_bundled_plugin_loads(); $this->should_load_calls = []; + $this->loaded_calls = []; + $this->skipped_calls = []; $this->error_calls = []; } @@ -764,12 +785,175 @@ static function () use ( $notices ): Writer_Interface { } /** - * The diagnostic channel a production site actually has. `_doing_it_wrong()` prints nothing - * without WP_DEBUG, so until this action existed a host had no way to be told that a bundled - * plugin it ships never made it into memory. + * `loaded` is the answer to "is this sub-plugin here?" without a `defined()` check of the host's + * own, so it has to fire once, for the sub-plugin that really was required. */ - public function test_it_announces_a_missing_bundled_file_with_the_sub_plugin_it_belongs_to(): void { + public function test_it_announces_a_load_once_with_the_sub_plugin(): void { + $this->record_lifecycle_actions(); + + $this->register(); + + $this->loader()->load_all(); + + $this->assertCount( 1, $this->loaded_calls ); + + $loaded = $this->loaded_calls[0]; + + $this->assertInstanceOf( Sub_Plugin::class, $loaded ); + $this->assertSame( 'give-recurring', $loaded->get_slug() ); + } + + /** + * The whole value of the action is that it means "this code is in memory now", so a gate that + * turned the sub-plugin away must not fire it. The second pass is the positive control: an + * empty list also describes a listener that never attached at all. + */ + public function test_it_announces_no_load_for_a_sub_plugin_that_was_skipped(): void { + $this->record_lifecycle_actions(); + + $this->register( [ 'enabled' => false ] ); + + $this->loader()->load_all(); + + $this->assertSame( [], $this->loaded_calls ); + + $this->register( [ 'slug' => 'give-fee-recovery' ] ); + + $this->loader()->load_all(); + + $this->assertCount( 1, $this->loaded_calls, 'The recorder must catch a load that really happened.' ); + } + + /** + * Behind the activation callback, not in front of it. A listener is host code that will reach + * into the sub-plugin it was just told about, and on a first-ever load the tables and options + * that code expects are the activation callback's work. + */ + public function test_it_announces_a_load_after_the_activation_callback(): void { + $order = []; + + $this->register( + [ + 'activation_callback' => static function () use ( &$order ): void { + $order[] = 'activation_callback'; + }, + ] + ); + + add_action( + 'give/plugin_absorber/loaded', + static function () use ( &$order ): void { + $order[] = 'loaded'; + } + ); + + $this->loader()->load_all(); + + $this->assertSame( [ 'activation_callback', 'loaded' ], $order ); + } + + /** + * One reason per gate, and the reason is what a host dispatches on — so each gate is pinned to + * the constant it reports, not merely to having reported something. + * + * @return Generator,1:string}> + */ + public function skipping_gates(): Generator { + yield 'disabled' => [ [ 'enabled' => false ], Loader::SKIPPED_DISABLED ]; + + yield 'dependencies unmet' => [ + [ 'dependency_check' => static fn() => false ], + Loader::SKIPPED_DEPENDENCIES_UNMET, + ]; + } + + /** + * @dataProvider skipping_gates + * + * @param array $overrides Config that trips the gate. + * @param string $reason Reason the gate has to report. + * + * @return void + */ + public function test_it_announces_a_skip_with_the_reason_for_the_gate( array $overrides, string $reason ): void { + $this->record_lifecycle_actions(); + + $this->register( $overrides ); + + $this->loader()->load_all(); + + $this->assertSame( [ [ 'give-recurring', $reason ] ], $this->skipped_calls ); + } + + /** + * The guard constant has its own case because tripping it means defining a constant, which the + * data provider cannot do reversibly. + */ + public function test_it_announces_a_skip_for_a_sub_plugin_that_is_already_loaded(): void { + $this->record_lifecycle_actions(); + + $constant = $this->define_guard( 'ABSORBER_ANNOUNCED_SKIP_GUARD' ); + + $this->register( [], $constant ); + + $this->loader()->load_all(); + + $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_ALREADY_LOADED ] ], $this->skipped_calls ); + } + + /** + * And the filter has its own because tripping it means a host filter rather than a config key. + */ + public function test_it_announces_a_skip_for_a_load_the_filter_vetoed(): void { + $this->record_lifecycle_actions(); + + $this->register(); + + add_filter( 'give/plugin_absorber/should_load', '__return_false' ); + + $this->loader()->load_all(); + + $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_FILTERED ] ], $this->skipped_calls ); + } + + /** + * `do_action()` runs host code, so the lifecycle actions are a new way for a request to die + * inside `plugins_loaded`. The `loaded` action fires from inside the per-sub-plugin try that has + * always guarded the `should_load` filter and the bundled file itself, so a listener that throws + * costs its own sub-plugin and nothing behind it. + */ + public function test_a_throwing_load_listener_does_not_take_the_request_down(): void { + $this->expect_incorrect_usage(); + + add_action( + 'give/plugin_absorber/loaded', + static function (): void { + throw new RuntimeException( 'the telemetry endpoint was unreachable' ); + } + ); + + $this->register( [ 'slug' => 'give-recurring' ] ); + $this->register( [ 'slug' => 'give-fee-recovery' ] ); + + $this->loader()->load_all(); + + // Both files: the listener throws after each require, and the sub-plugin behind the first one + // still has to load. Without the guard neither number is ever read, because the throw ends the + // request. + $this->assertSame( 2, $this->bundled_plugin_loads() ); + $this->assert_the_library_reported_incorrect_usage_saying( + 'threw while loading', + 'A listener that threw is the sub-plugin it was announcing being abandoned, and has to say so.' + ); + } + + /** + * The file gate is two things at once — a build the host has to fix, and a sub-plugin that is not + * going to be there — so it is the one gate that announces down both channels. + */ + public function test_it_announces_a_missing_bundled_file_as_both_an_error_and_a_skip(): void { $this->record_error_action(); + $this->record_lifecycle_actions(); $this->expect_incorrect_usage(); $path = $this->missing_bundled_plugin_file(); @@ -784,6 +968,7 @@ public function test_it_announces_a_missing_bundled_file_with_the_sub_plugin_it_ $this->loader()->load_all(); + $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_FILE_UNREADABLE ] ], $this->skipped_calls ); $this->assertCount( 1, $this->error_calls ); $this->assertStringContainsString( $path, @@ -931,6 +1116,42 @@ private function loader(): Loader { return $this->resolve( Loader::class ); } + /** + * Listen to both lifecycle actions at once. + * + * One helper rather than two, because most of these tests assert on one list *and* on the other + * staying empty — a skip that also fired `loaded`, a load that also fired `skipped` — and a test + * that only attached the listener it names could not see the other half. + * + * The closures take references to the properties and are `static`: uopz is not involved here, but + * the same shape keeps a listener from holding the test object alive on a hook. + * + * @return void + */ + private function record_lifecycle_actions(): void { + $loaded = &$this->loaded_calls; + $skipped = &$this->skipped_calls; + + add_action( + 'give/plugin_absorber/loaded', + static function ( $sub_plugin ) use ( &$loaded ): void { + $loaded[] = $sub_plugin; + } + ); + + add_action( + 'give/plugin_absorber/skipped', + static function ( $sub_plugin, $reason ) use ( &$skipped ): void { + $skipped[] = [ + $sub_plugin instanceof Sub_Plugin ? $sub_plugin->get_slug() : $sub_plugin, + $reason, + ]; + }, + 10, + 2 + ); + } + /** * Listen to the `error` action, keeping both of its arguments. * From cd203b2e6649f304a35d8be295f99be49bc01361 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 24 Aug 2026 14:34:59 +0200 Subject: [PATCH 2/4] Report a throwing lifecycle listener as a listener, not as a load that failed --- docs/actions.md | 23 +++++++++++---- src/Loader.php | 60 +++++++++++++++++++++++++++++++++------ tests/unit/LoaderTest.php | 43 ++++++++++++++++++++++++---- 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/docs/actions.md b/docs/actions.md index 302c178..18332d0 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -9,8 +9,10 @@ you override. `{prefix}` is the value passed to `Config::set_hook_prefix()`. | `{prefix}/plugin_absorber/skipped` | `Sub_Plugin $sub_plugin`, `string $reason` | A gate turned a sub-plugin away. | | `{prefix}/plugin_absorber/error` | `string $message`, `Sub_Plugin\|null $sub_plugin` | Something went wrong that a developer has to fix. | -Every one of them also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on. These -are the channel that is not — reach for them for a log line, a health check, or a support tool. +`error` also goes to `_doing_it_wrong()`, which is silent unless `WP_DEBUG` is on; the action is the +channel that is not, which is what makes it worth listening to on a production site. `loaded` and +`skipped` are announcements rather than failures and go nowhere else. Reach for any of them for a log +line, a health check, or a support tool. ## Loading @@ -53,6 +55,11 @@ add_action( 'give/plugin_absorber/skipped', function ( $sub_plugin, $reason ) { The values are fixed API and will not change. New reasons may be added, so treat one you do not recognise as a plain skip rather than as an error. +`loaded` and `skipped` do not add up to everything registered, so do not count on them to. A +sub-plugin whose `enabled`, `dependency_check` or `should_load` callable throws, or whose bundled +file throws as it is required, announces `error` and neither of these; and a `DEACTIVATE` conflict +redirects before the load pass runs at all, so on that request no sub-plugin announces anything. + ## Errors `error` carries the sentence a developer needs, and the sub-plugin it belongs to when it belongs to @@ -80,7 +87,11 @@ Two things to know: These fire from inside `plugins_loaded` and from inside the handlers that keep a failing sub-plugin from white-screening the site, so a listener that throws is caught rather than allowed out. A throw -from `loaded` or `skipped` costs that sub-plugin the rest of its load pass and nothing behind it; a -throw from `error` costs nothing at all and is itself reported through `_doing_it_wrong()`. That is -a backstop, not a licence — a listener here runs on every request the site serves, so keep it cheap -and keep it quiet. +from `loaded` or `skipped` costs nothing: by the time `loaded` fires the file is required and the +activation callback has run, and a `skipped` announcement is the last thing that happens to that +sub-plugin either way. The throw is reported on `error` as what it is — a listener, named by the +hook it is on — rather than as the sub-plugin having failed, so a health check watching `error` does +not read your own bug as a broken load. A throw from `error` costs nothing either and is reported +through `_doing_it_wrong()` alone, since announcing it again is how a listener that always throws +would recurse. That is a backstop, not a licence — a listener here runs on every request the site +serves, so keep it cheap and keep it quiet. diff --git a/src/Loader.php b/src/Loader.php index 71073de..8aaed9b 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -153,10 +153,13 @@ public function load_all(): void { // A re-declaration is the one failure this cannot catch, because PHP does not raise it as // a Throwable -- which is what the guard constant, checked before any of this, is for. // - // The loaded and skipped actions run host code inside this same try, exactly as the - // should_load filter already does, so a listener that throws costs its own sub-plugin and - // nothing behind it. Reporting the failure cannot add one of its own: report_error() - // swallows whatever a listener on the error action throws. + // The loaded and skipped actions run host code too, but they catch their own throws + // rather than falling to this one: by the time `loaded` fires the require has happened, + // the guard constant is defined and the activation callback has run, so a listener's + // throw arriving here would report a sub-plugin that is loaded and healthy as one that + // was abandoned -- on the channel a host built its log line and its health check on. + // Reporting a failure cannot add one of its own either way: report_error() swallows + // whatever a listener on the error action throws. try { $this->load( $sub_plugin ); } catch ( Throwable $thrown ) { @@ -265,15 +268,13 @@ private function load( Sub_Plugin $sub_plugin ): void { // off the callback: from here the throw is caught a frame up with the require and the // activation already done, where from in front of it the throw would skip the callback // entirely and leave it to be retried, silently, on every request for ever. - do_action( Config::get_hook_name( 'loaded' ), $sub_plugin ); + $this->announce( Config::get_hook_name( 'loaded' ), [ $sub_plugin ], $sub_plugin ); } /** * Say that a gate turned this sub-plugin away, and which gate it was. * - * The hook name is built in one place for the reason `Config` gives for owning the prefix at all, - * and the call is guarded by nothing: it runs inside the per-sub-plugin `catch` in `load_all()`, - * which is the same guard the `should_load` filter beside it has always had. + * The hook name is built in one place for the reason `Config` gives for owning the prefix at all. * * @since 1.0.0 * @@ -285,6 +286,47 @@ private function load( Sub_Plugin $sub_plugin ): void { * @return void */ private function announce_skip( Sub_Plugin $sub_plugin, string $reason ): void { - do_action( Config::get_hook_name( 'skipped' ), $sub_plugin, $reason ); + $this->announce( Config::get_hook_name( 'skipped' ), [ $sub_plugin, $reason ], $sub_plugin ); + } + + /** + * Fire one lifecycle action, and keep what a listener throws out of the load pass's own report. + * + * The throw is caught here rather than a frame up, because `load_all()`'s per-sub-plugin catch + * has exactly one sentence and it is "threw while loading, so it was abandoned". For a listener + * on `loaded` that sentence is false in both halves: the require happened, the guard constant is + * defined and the activation callback has already run, so the sub-plugin is loaded and nothing + * about it was abandoned. A host listening to both channels would get `loaded` and `error` for + * the same sub-plugin in the same pass, and the log line, health check and support tool that + * `error` exists for would each read a successful load as a failed one. The same is true of a + * listener on `skipped`, which reports a skip that really did happen. + * + * So the failure is announced as what it is -- somebody's listener, named by the hook it is on -- + * in the sentence `Traits\Reports_Errors` already uses for a listener on the error action. It + * still costs nothing behind it: the sub-plugin is finished with either way, and the loop moves + * on to the next. + * + * @since 1.0.0 + * + * @param string $hook Fully qualified hook name. + * @param array $arguments Arguments to pass to the listeners. + * @param Sub_Plugin $sub_plugin Sub-plugin the announcement is about. + * + * @return void + */ + private function announce( string $hook, array $arguments, Sub_Plugin $sub_plugin ): void { + try { + do_action_ref_array( $hook, $arguments ); + } catch ( Throwable $thrown ) { + self::report_error( + self::class, + sprintf( + 'A listener on %s threw, and was abandoned: %s', + $hook, + $thrown->getMessage() + ), + $sub_plugin + ); + } } } diff --git a/tests/unit/LoaderTest.php b/tests/unit/LoaderTest.php index 1f9733b..e8917c0 100644 --- a/tests/unit/LoaderTest.php +++ b/tests/unit/LoaderTest.php @@ -801,6 +801,11 @@ public function test_it_announces_a_load_once_with_the_sub_plugin(): void { $this->assertInstanceOf( Sub_Plugin::class, $loaded ); $this->assertSame( 'give-recurring', $loaded->get_slug() ); + + // The other half, and the reason both listeners go on together: a load that also announced a + // skip is a gate chain that ran on past its own `return`, which asserting on one list alone + // would never see. + $this->assertSame( [], $this->skipped_calls ); } /** @@ -883,6 +888,7 @@ public function test_it_announces_a_skip_with_the_reason_for_the_gate( array $ov $this->loader()->load_all(); $this->assertSame( [ [ 'give-recurring', $reason ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); } /** @@ -899,6 +905,7 @@ public function test_it_announces_a_skip_for_a_sub_plugin_that_is_already_loaded $this->loader()->load_all(); $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_ALREADY_LOADED ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); } /** @@ -914,15 +921,23 @@ public function test_it_announces_a_skip_for_a_load_the_filter_vetoed(): void { $this->loader()->load_all(); $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_FILTERED ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); } /** * `do_action()` runs host code, so the lifecycle actions are a new way for a request to die - * inside `plugins_loaded`. The `loaded` action fires from inside the per-sub-plugin try that has - * always guarded the `should_load` filter and the bundled file itself, so a listener that throws - * costs its own sub-plugin and nothing behind it. + * inside `plugins_loaded`. A listener that throws costs its own sub-plugin and nothing behind it. + * + * And it costs its own sub-plugin nothing either, which is the half worth pinning: by the time + * `loaded` fires the require has happened, the guard constant is defined and the activation + * callback has run. Left to the per-sub-plugin catch in `load_all()`, the throw would be reported + * as "threw while loading, so it was abandoned" — a sentence that is false in both halves, on the + * one channel a host is expected to build a log line and a health check on. It is announced as + * what it is instead: somebody's listener, named by the hook it is on. */ public function test_a_throwing_load_listener_does_not_take_the_request_down(): void { + $this->record_error_action(); + $this->record_lifecycle_actions(); $this->expect_incorrect_usage(); add_action( @@ -941,9 +956,26 @@ static function (): void { // still has to load. Without the guard neither number is ever read, because the throw ends the // request. $this->assertSame( 2, $this->bundled_plugin_loads() ); + + $this->assertCount( 2, $this->error_calls ); + + foreach ( $this->error_calls as $error ) { + $message = is_string( $error['message'] ) ? $error['message'] : ''; + + $this->assertStringContainsString( 'A listener on give/plugin_absorber/loaded threw', $message ); + $this->assertStringContainsString( 'the telemetry endpoint was unreachable', $message ); + $this->assertStringNotContainsString( + 'threw while loading', + $message, + 'The sub-plugin loaded. Reporting it as abandoned would have a health check watching' + . ' this channel call a healthy load a failed one.' + ); + } + $this->assert_the_library_reported_incorrect_usage_saying( - 'threw while loading', - 'A listener that threw is the sub-plugin it was announcing being abandoned, and has to say so.' + 'A listener on give/plugin_absorber/loaded threw', + 'The developer channel carries the same sentence the action does, and names the hook so' + . ' the host knows whose listener to go and look at.' ); } @@ -969,6 +1001,7 @@ public function test_it_announces_a_missing_bundled_file_as_both_an_error_and_a_ $this->loader()->load_all(); $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_FILE_UNREADABLE ] ], $this->skipped_calls ); + $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); $this->assertCount( 1, $this->error_calls ); $this->assertStringContainsString( $path, From fd3a9b419fd69febf161524cba3cacb09dbdd22f Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 24 Aug 2026 14:46:31 +0200 Subject: [PATCH 3/4] Record the lifecycle actions where the durable document lists the rest --- AGENTS.md | 17 +++++++++++------ src/Loader.php | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 321587a..98e1419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,7 +193,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`. | `src/Absorber.php` | Static facade: registration, `boot()`, the accessors, and the two notice trampolines. Holds no collaborator's state. | | `src/Provider.php` | Binds every collaborator; the only file that names a default implementation. | | `src/Boot/Scheduler.php` | Hook wiring and boot timing: the sequence, the priorities, and the fallback for a host that boots too late. | -| `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback. | +| `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback, and the `loaded`/`skipped` announcements. | | `src/Sub_Plugin.php` | Value object; validates config and answers what it can without a container-bound collaborator. | | `src/Conflict_Policy.php` | The three policy constants, `default()`, `is_valid()`. | | `src/Plugin/` | `Deactivator` (turns the standalone off), `Checker` (answers whether a plugin is active in either scope, and whether it is network-active — `is_plugin_active_for_network()`, which is `false` off a network so no caller needs an `is_multisite()` guard), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. | @@ -403,8 +403,10 @@ runnable inline as well as wirable. `{$hook_prefix}/plugin_absorber/conflict_notice_message`, `{$hook_prefix}/plugin_absorber/dependency_notice_message` and `{$hook_prefix}/plugin_absorber/stranding_notice_message` (all four `Sub_Plugin`) -- Actions: `{$hook_prefix}/plugin_absorber/error` (`Traits\Reports_Errors`, from every reporting site - in the library) +- Actions: `{$hook_prefix}/plugin_absorber/loaded` and `{$hook_prefix}/plugin_absorber/skipped` + (`Loader`, one per sub-plugin the load pass finished with), + `{$hook_prefix}/plugin_absorber/error` (`Traits\Reports_Errors`, from every reporting site in the + library) - Options: `{$option_prefix}_plugin_absorber_activations` (`Activator`), `{$option_prefix}_plugin_absorber_notices` (`Notices\Store`) @@ -529,9 +531,12 @@ against real WordPress state. `Bootstrap_Test_Case.php` is the abstract parent o somebody else's code — `enabled`, `dependency_check`, `activation_callback`, `conflict_policy`, the notice messages, the `should_load` filter, the bundled file a `require` runs top to bottom, the standalone's own deactivation hook, and every listener on the actions this library fires. - `Traits\Reports_Errors` is the exception that catches its own: reporting a failure may not raise a - second one, so a throw from an `error` listener is swallowed there rather than handed back up to - the step that was already failing. The one failure none of this can catch is a re-declaration + Two of those catch their own rather than falling to the enclosing one. `Traits\Reports_Errors` + swallows what an `error` listener throws, because reporting a failure may not raise a second one. + `Loader::announce()` swallows what a `loaded` or `skipped` listener throws, because by then the + require has happened and the activation callback has run — left to the per-sub-plugin catch, a + listener's bug would be reported as the sub-plugin having been abandoned, on the channel a host + built its health check on. The one failure none of this can catch is a re-declaration fatal, which PHP does not raise as a `Throwable`; the guard constant, checked before the require, is what prevents that one. - **The guard constant and the standalone basename are two separate keys.** No constant does double diff --git a/src/Loader.php b/src/Loader.php index 8aaed9b..00a33a3 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -265,7 +265,7 @@ private function load( Sub_Plugin $sub_plugin ): void { // reach into the sub-plugin it was just told about, and on the first-ever load the tables and // options that code expects are the activation callback's work -- so announcing first would // hand a host a plugin that is loaded but not yet set up. It also keeps a throwing listener - // off the callback: from here the throw is caught a frame up with the require and the + // off the callback: from here announce() catches the throw with the require and the // activation already done, where from in front of it the throw would skip the callback // entirely and leave it to be retried, silently, on every request for ever. $this->announce( Config::get_hook_name( 'loaded' ), [ $sub_plugin ], $sub_plugin ); From 5f051cd6f057c19d8c980b7f489285ce37d642af Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 24 Aug 2026 15:07:09 +0200 Subject: [PATCH 4/4] Give the skip reasons a class of their own `Loader` is bound by class name and a host may replace it outright; a replacement announcing the same vocabulary should not have to import the implementation it swapped out to name a reason. `Skip_Reason` sits at the root beside `Conflict_Policy` for the reason that one is not on `Conflict\Resolver`, and carries no behaviour, since the library only ever emits a reason. --- AGENTS.md | 11 +++++- docs/actions.md | 14 ++++---- src/Loader.php | 68 ++++------------------------------- src/Skip_Reason.php | 76 +++++++++++++++++++++++++++++++++++++++ tests/unit/LoaderTest.php | 11 +++--- 5 files changed, 105 insertions(+), 75 deletions(-) create mode 100644 src/Skip_Reason.php diff --git a/AGENTS.md b/AGENTS.md index 98e1419..59aa15f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,6 +196,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`. | `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback, and the `loaded`/`skipped` announcements. | | `src/Sub_Plugin.php` | Value object; validates config and answers what it can without a container-bound collaborator. | | `src/Conflict_Policy.php` | The three policy constants, `default()`, `is_valid()`. | +| `src/Skip_Reason.php` | The five reasons the `skipped` action carries, one per gate in the load pass. | | `src/Plugin/` | `Deactivator` (turns the standalone off), `Checker` (answers whether a plugin is active in either scope, and whether it is network-active — `is_plugin_active_for_network()`, which is `false` off a network so no caller needs an `is_multisite()` guard), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. | | `src/Registry/` | `Registrar` (holds registered `Sub_Plugin` objects), `Reader` (the registration buffer, drained into the registrar on the way past; the object every pass reads the registry through), `Contracts\Registrar_Interface`. | | `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. | @@ -262,6 +263,13 @@ is_readable()`, not `file_exists()`: that last is true for a directory and for a permission, and `require_once` fatals on both. Only the dependency gate queues a notice; an unreadable file is a broken build in the host plugin and reports through `_doing_it_wrong()`. +**Each gate's reason is a `Skip_Reason` constant, not a constant on `Loader`.** What the five values +belong to is the `skipped` action rather than the pass that fires it: `Loader` is bound by class name +and a host may replace it outright, and a replacement announcing the same vocabulary should not have +to import the implementation it swapped out to name one — the reason `Conflict_Policy` sits at the +root rather than on `Conflict\Resolver`. Unlike `Conflict_Policy` it carries no behaviour: the library +only ever emits a reason, never receives one, so an `is_valid()` there would have no caller. + The activation callback is the last of those and runs through `Activator_Interface`, which `Loader` takes as a constructor argument like the writer and the registry reader. Last, because a bundled plugin is included rather @@ -404,7 +412,8 @@ runnable inline as well as wirable. `{$hook_prefix}/plugin_absorber/dependency_notice_message` and `{$hook_prefix}/plugin_absorber/stranding_notice_message` (all four `Sub_Plugin`) - Actions: `{$hook_prefix}/plugin_absorber/loaded` and `{$hook_prefix}/plugin_absorber/skipped` - (`Loader`, one per sub-plugin the load pass finished with), + (`Loader`, one per sub-plugin the load pass finished with; the skip reasons are `Skip_Reason` + constants), `{$hook_prefix}/plugin_absorber/error` (`Traits\Reports_Errors`, from every reporting site in the library) - Options: `{$option_prefix}_plugin_absorber_activations` (`Activator`), diff --git a/docs/actions.md b/docs/actions.md index 18332d0..7cff5d6 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -36,17 +36,17 @@ The second argument is one of five values. Compare against the constant, not the | Constant | Value | Meaning | |---|---|---| -| `Loader::SKIPPED_DISABLED` | `disabled` | The `enabled` key, or the callable behind it, said no. | -| `Loader::SKIPPED_ALREADY_LOADED` | `already_loaded` | The guard constant was already defined — usually a standalone copy that loaded first. | -| `Loader::SKIPPED_DEPENDENCIES_UNMET` | `dependencies_unmet` | `dependency_check` said the requirements are not met. The one skip that also queues a [notice](notices.md). | -| `Loader::SKIPPED_FILE_UNREADABLE` | `file_unreadable` | `bundled_plugin_file` does not name a readable file. | -| `Loader::SKIPPED_FILTERED` | `filtered` | The [`should_load` filter](filters.md#the-load-gate) returned something falsy. | +| `Skip_Reason::DISABLED` | `disabled` | The `enabled` key, or the callable behind it, said no. | +| `Skip_Reason::ALREADY_LOADED` | `already_loaded` | The guard constant was already defined — usually a standalone copy that loaded first. | +| `Skip_Reason::DEPENDENCIES_UNMET` | `dependencies_unmet` | `dependency_check` said the requirements are not met. The one skip that also queues a [notice](notices.md). | +| `Skip_Reason::FILE_UNREADABLE` | `file_unreadable` | `bundled_plugin_file` does not name a readable file. | +| `Skip_Reason::FILTERED` | `filtered` | The [`should_load` filter](filters.md#the-load-gate) returned something falsy. | ```php -use Nexcess\PluginAbsorber\Loader; +use Nexcess\PluginAbsorber\Skip_Reason; add_action( 'give/plugin_absorber/skipped', function ( $sub_plugin, $reason ) { - if ( $reason === Loader::SKIPPED_ALREADY_LOADED ) { + if ( $reason === Skip_Reason::ALREADY_LOADED ) { my_log( sprintf( '%s deferred to a standalone copy.', $sub_plugin->get_slug() ) ); } }, 10, 2 ); diff --git a/src/Loader.php b/src/Loader.php index 00a33a3..6cb1266 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -28,62 +28,6 @@ class Loader { use Guards_Hook_Prefix; use Reports_Errors; - /** - * The `enabled` key, or the callable behind it, said no. - * - * This and the four that follow are the values the `skipped` action's second argument takes, and - * they are public API from the moment they ship: a host comparing against - * `Loader::SKIPPED_DISABLED` rather than against `'disabled'` is the point of naming them at all. - * They sit on this class because each one names a gate in `load()` and nothing outside the load - * pass decides one — where `Conflict_Policy` earns a class of its own by carrying behaviour - * (`default()`, `is_valid()`) and by being read by the value object, the resolver and the host - * alike. - * - * @since 1.0.0 - * - * @var string - */ - public const SKIPPED_DISABLED = 'disabled'; - - /** - * The guard constant was already defined, so the code is in memory — a standalone copy loaded - * ahead of this pass, or a second registration of the same plugin. - * - * @since 1.0.0 - * - * @var string - */ - public const SKIPPED_ALREADY_LOADED = 'already_loaded'; - - /** - * The `dependency_check` callable said the requirements are not met. The one skip that also - * queues a notice for the site owner. - * - * @since 1.0.0 - * - * @var string - */ - public const SKIPPED_DEPENDENCIES_UNMET = 'dependencies_unmet'; - - /** - * `bundled_plugin_file` does not name a readable file. A broken build in the host plugin, so it - * is announced through `error` as well as here. - * - * @since 1.0.0 - * - * @var string - */ - public const SKIPPED_FILE_UNREADABLE = 'file_unreadable'; - - /** - * The `should_load` filter returned something falsy. - * - * @since 1.0.0 - * - * @var string - */ - public const SKIPPED_FILTERED = 'filtered'; - /** * @since 1.0.0 * @@ -189,7 +133,7 @@ public function load_all(): void { */ private function load( Sub_Plugin $sub_plugin ): void { if ( ! $sub_plugin->is_enabled() ) { - $this->announce_skip( $sub_plugin, self::SKIPPED_DISABLED ); + $this->announce_skip( $sub_plugin, Skip_Reason::DISABLED ); return; } @@ -199,14 +143,14 @@ private function load( Sub_Plugin $sub_plugin ): void { // means "the plugin is already running" -- warning that requirements are unmet for a // plugin the admin can see working would be worse than useless. if ( $sub_plugin->is_already_loaded() ) { - $this->announce_skip( $sub_plugin, self::SKIPPED_ALREADY_LOADED ); + $this->announce_skip( $sub_plugin, Skip_Reason::ALREADY_LOADED ); return; } if ( ! $sub_plugin->are_dependencies_met() ) { $this->notices->queue_dependency_notice( $sub_plugin ); - $this->announce_skip( $sub_plugin, self::SKIPPED_DEPENDENCIES_UNMET ); + $this->announce_skip( $sub_plugin, Skip_Reason::DEPENDENCIES_UNMET ); return; } @@ -232,7 +176,7 @@ private function load( Sub_Plugin $sub_plugin ): void { ), $sub_plugin ); - $this->announce_skip( $sub_plugin, self::SKIPPED_FILE_UNREADABLE ); + $this->announce_skip( $sub_plugin, Skip_Reason::FILE_UNREADABLE ); return; } @@ -243,7 +187,7 @@ private function load( Sub_Plugin $sub_plugin ): void { $should_load = apply_filters( Config::get_hook_name( 'should_load' ), true, $sub_plugin ); if ( ! $should_load ) { - $this->announce_skip( $sub_plugin, self::SKIPPED_FILTERED ); + $this->announce_skip( $sub_plugin, Skip_Reason::FILTERED ); return; } @@ -279,7 +223,7 @@ private function load( Sub_Plugin $sub_plugin ): void { * @since 1.0.0 * * @param Sub_Plugin $sub_plugin Sub-plugin that was skipped. - * @param string $reason One of this class's `SKIPPED_*` constants. + * @param string $reason One of the `Skip_Reason` constants. * * @throws Config_Exception When no hook prefix has been set. * diff --git a/src/Skip_Reason.php b/src/Skip_Reason.php new file mode 100644 index 0000000..38e7559 --- /dev/null +++ b/src/Skip_Reason.php @@ -0,0 +1,76 @@ +,1:string}> */ public function skipping_gates(): Generator { - yield 'disabled' => [ [ 'enabled' => false ], Loader::SKIPPED_DISABLED ]; + yield 'disabled' => [ [ 'enabled' => false ], Skip_Reason::DISABLED ]; yield 'dependencies unmet' => [ [ 'dependency_check' => static fn() => false ], - Loader::SKIPPED_DEPENDENCIES_UNMET, + Skip_Reason::DEPENDENCIES_UNMET, ]; } @@ -904,7 +905,7 @@ public function test_it_announces_a_skip_for_a_sub_plugin_that_is_already_loaded $this->loader()->load_all(); - $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_ALREADY_LOADED ] ], $this->skipped_calls ); + $this->assertSame( [ [ 'give-recurring', Skip_Reason::ALREADY_LOADED ] ], $this->skipped_calls ); $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); } @@ -920,7 +921,7 @@ public function test_it_announces_a_skip_for_a_load_the_filter_vetoed(): void { $this->loader()->load_all(); - $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_FILTERED ] ], $this->skipped_calls ); + $this->assertSame( [ [ 'give-recurring', Skip_Reason::FILTERED ] ], $this->skipped_calls ); $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); } @@ -1000,7 +1001,7 @@ public function test_it_announces_a_missing_bundled_file_as_both_an_error_and_a_ $this->loader()->load_all(); - $this->assertSame( [ [ 'give-recurring', Loader::SKIPPED_FILE_UNREADABLE ] ], $this->skipped_calls ); + $this->assertSame( [ [ 'give-recurring', Skip_Reason::FILE_UNREADABLE ] ], $this->skipped_calls ); $this->assertSame( [], $this->loaded_calls, 'A sub-plugin a gate turned away was not loaded.' ); $this->assertCount( 1, $this->error_calls ); $this->assertStringContainsString(