Skip to content
Open
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
26 changes: 17 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`.
| `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. |
| `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation-error screen for a registered standalone), `Contracts\Resolver_Interface`. |
| `src/Traits/` | `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing). Cross-cutting only: a trait used by one folder lives in that folder. |
| `src/Traits/` | `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing), `Reports_Errors` (the one way a failure is announced: `_doing_it_wrong()` and the `error` action, and it swallows what a listener throws). Cross-cutting only: a trait used by one folder lives in that folder. |
| `src/Notices/` | `Writer` (what a notice says, stored under `slug:type` — `merge`, `conflict`, `stranding`, `dependency`), `Presenter` (who may consume it, render-then-clear), `Store` (keeps it), `Renderer` (draws it, `notice-error` for `dependency` and `notice-warning` for the rest), `Contracts\Writer_Interface`. |
| `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Activator_Interface`, `Config_Exception`. |

Expand Down Expand Up @@ -403,11 +403,14 @@ 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)
- Options: `{$option_prefix}_plugin_absorber_activations` (`Activator`),
`{$option_prefix}_plugin_absorber_notices` (`Notices\Store`)

Both are built in `Config` — `get_hook_name()` and `get_option_name()` — so nothing else assembles
the segment between the host's prefix and the key's own name. The two differ in one respect:
The names are built in `Config` — `get_hook_name()` and `get_option_name()` — so nothing else assembles
the segment between the host's prefix and the key's own name. Hook names and option names differ in
one respect:
`{$option_prefix}` is the hook prefix lowercased with hyphens folded to underscores, because the
prefix validator admits `A-Z` and `-` and a hook-naming value should not reach a storage key
verbatim. Hook names keep the host's casing exactly as it passed it.
Expand Down Expand Up @@ -524,8 +527,11 @@ against real WordPress state. `Bootstrap_Test_Case.php` is the abstract parent o
`Conflict\Resolver::resolve_all()` catch *per sub-plugin* as well, because one sub-plugin's throw
must not take the ones behind it in the registration order with it. Everything past those catches is
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, and the
standalone's own deactivation hook. The one failure none of this can catch is a re-declaration
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
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
Expand Down Expand Up @@ -611,7 +617,9 @@ Branch names are `NN-topic`. Never open PR N+1 before PR N's branch exists. `mai
after every merge.

- **PR size cap:** ≤10 files, tests and test infrastructure excluded. No logic-bearing PR exceeds 4
source files.
source files — with one exception: a change that wires one decision through every site that
already does the same job may exceed it, where the added files are call-site swaps carrying no
argument of their own. Say so in the body, and say which files those are.
- **Commits: no co-author trailers, ever.**
- **PR body is exactly three parts, nothing else** — no boilerplate headings, no restating the diff,
no checklists, and no "Verify" section: the commands are in this file and the coverage is in the
Expand Down Expand Up @@ -759,9 +767,9 @@ under a `Nexcess\SubPluginLoader\` namespace, with a `Config::set_version()` tha
`ob_start()` approach the `wp_admin_notice_markup` filter replaced.

Human-facing docs are `README.md` plus `docs/installing.md`, `docs/configuration.md`,
`docs/recipes.md`, `docs/conflict-handling.md`, `docs/filters.md`, `docs/notices.md` and
`docs/extending.md`. Keep them short and keep rationale here or in code comments — do not grow the
README back. They are written for a host developer integrating the library, not for a maintainer:
`docs/recipes.md`, `docs/conflict-handling.md`, `docs/filters.md`, `docs/actions.md`,
`docs/notices.md` and `docs/extending.md`. Keep them short and keep rationale here or in code
comments — do not grow the README back. They are written for a host developer integrating the library, not for a maintainer:
`docs/extending.md` is the only one that names internal classes, and every other file describes
behaviour instead. `docs/` is `export-ignore`d and
`README.md` is not, so a link from the README into `docs/` must be an absolute repository URL; links
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +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.
- [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
Expand All @@ -73,6 +74,7 @@ source.
[recipes]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/recipes.md
[conflicts]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/conflict-handling.md
[filters]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/filters.md
[actions]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/actions.md
[notices]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/notices.md
[extending]: https://github.com/stellarwp/plugin-absorber/blob/main/docs/extending.md
[tests]: https://github.com/stellarwp/plugin-absorber/blob/main/tests/README.md
Expand Down
37 changes: 37 additions & 0 deletions docs/actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Actions

What the library tells you as it runs. [Filters](filters.md) are the other direction — the values
you override. `{prefix}` is the value passed to `Config::set_hook_prefix()`.

| Action | Arguments | Fires when |
|---|---|---|
| `{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.

## Errors

`error` carries the sentence a developer needs, and the sub-plugin it belongs to when it belongs to
one — a duplicate slug, a broken bundled file, a sub-plugin whose own code threw, a conflict that
could not be resolved. It is `null` for a failure that belongs to no single registration: a boot
that came too late to wire, a pass that threw before it reached any sub-plugin, notices that could
not be rendered.

```php
add_action( 'give/plugin_absorber/error', function ( $message, $sub_plugin ) {
error_log( 'plugin-absorber: ' . $message );
}, 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.

## 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.
3 changes: 2 additions & 1 deletion docs/filters.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Filters

`{prefix}` is the value passed to `Config::set_hook_prefix()`.
What you override. [Actions](actions.md) are the other direction — what the library tells you as it
runs. `{prefix}` is the value passed to `Config::set_hook_prefix()`.

| Filter | Arguments | Purpose |
|---|---|---|
Expand Down
12 changes: 6 additions & 6 deletions src/Absorber.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Nexcess\PluginAbsorber\Registry\Contracts\Registrar_Interface;
use Nexcess\PluginAbsorber\Registry\Reader;
use Nexcess\PluginAbsorber\Traits\Guards_Hook_Prefix;
use Nexcess\PluginAbsorber\Traits\Reports_Errors;
use Throwable;

/**
Expand All @@ -34,6 +35,7 @@
*/
final class Absorber {
use Guards_Hook_Prefix;
use Reports_Errors;

/**
* Whether the hooks have been wired.
Expand Down Expand Up @@ -180,10 +182,9 @@ public static function render_notices(): void {
try {
self::collaborator( Presenter::class )->render();
} catch ( Throwable $thrown ) {
_doing_it_wrong(
self::report_error(
self::class . '::render_notices',
sprintf( 'The notices could not be rendered: %s', $thrown->getMessage() ),
'1.0.0'
sprintf( 'The notices could not be rendered: %s', $thrown->getMessage() )
);
}
}
Expand Down Expand Up @@ -215,10 +216,9 @@ public static function filter_activation_error_markup( $markup ): string {
try {
return self::collaborator( Rewriter::class )->rewrite( $markup );
} catch ( Throwable $thrown ) {
_doing_it_wrong(
self::report_error(
self::class . '::filter_activation_error_markup',
sprintf( 'The activation error notice could not be rewritten: %s', $thrown->getMessage() ),
'1.0.0'
sprintf( 'The activation error notice could not be rewritten: %s', $thrown->getMessage() )
);

return $markup;
Expand Down
27 changes: 21 additions & 6 deletions src/Boot/Scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Nexcess\PluginAbsorber\Conflict\Detector;
use Nexcess\PluginAbsorber\Conflict\Gatekeeper;
use Nexcess\PluginAbsorber\Loader;
use Nexcess\PluginAbsorber\Traits\Reports_Errors;
use StellarWP\ContainerContract\ContainerInterface;
use Throwable;
use WP_Hook;
Expand All @@ -28,6 +29,8 @@
* @since 1.0.0
*/
class Scheduler {
use Reports_Errors;

/**
* plugins_loaded priority the load pass runs at.
*
Expand Down Expand Up @@ -121,10 +124,14 @@ public function wire(): void {
// hook mistake there is -- would otherwise mean nothing loads at all, with no warning and
// a site that looks entirely healthy.
if ( $this->wiring_window_has_closed() ) {
_doing_it_wrong(
// The one report in this library raised before a hook has fired rather than from inside
// one, and the only one a host can still act on in the same request. It reaches the error
// action only when the prefix is already set: `boot()` requires a container and not a
// prefix, so a host that skipped `set_hook_prefix()` gets the developer channel alone --
// the same answer the prefix guard gives on every other path, for the same reason.
self::report_error(
Absorber::class . '::boot',
'Absorber::boot() must run before plugins_loaded priority 5. Resolving and loading inline instead.',
'1.0.0'
'Absorber::boot() must run before plugins_loaded priority 5. Resolving and loading inline instead.'
);

// In the order the hooks would have run them.
Expand Down Expand Up @@ -252,6 +259,15 @@ private static function load( ContainerInterface $container ): void {
/**
* Tell the developer which step was abandoned, and why.
*
* Called from inside both backstop `catch` blocks, which is the sharpest place an announcement
* can sit: a listener throwing here would escape the handler whose entire purpose is that
* nothing escapes it, on `plugins_loaded`, on every request. `report_error()` catches its own
* listeners for exactly this call site.
*
* No sub-plugin is named. What threw is the step — a gate, the probe, a host's resolver, or a
* collaborator the container could not build — and by the time it reaches here there is no
* telling which registration, if any, it was about.
*
* @since 1.0.0
*
* @param string $step Step that threw, named as the sequence names it.
Expand All @@ -261,10 +277,9 @@ private static function load( ContainerInterface $container ): void {
* @return void
*/
private static function report_a_step_that_threw( string $step, string $consequence, Throwable $thrown ): void {
_doing_it_wrong(
self::report_error(
self::class,
sprintf( 'The %s threw, so %s: %s', $step, $consequence, $thrown->getMessage() ),
'1.0.0'
sprintf( 'The %s threw, so %s: %s', $step, $consequence, $thrown->getMessage() )
);
}

Expand Down
10 changes: 8 additions & 2 deletions src/Conflict/Resolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Nexcess\PluginAbsorber\Registry\Reader;
use Nexcess\PluginAbsorber\Sub_Plugin;
use Nexcess\PluginAbsorber\Traits\Guards_Hook_Prefix;
use Nexcess\PluginAbsorber\Traits\Reports_Errors;
use Throwable;

/**
Expand All @@ -37,6 +38,7 @@
*/
class Resolver implements Resolver_Interface {
use Guards_Hook_Prefix;
use Reports_Errors;

/**
* @since 1.0.0
Expand Down Expand Up @@ -137,14 +139,18 @@ public function resolve_all(): void {
$standalone_gone = true;
}
} catch ( Throwable $thrown ) {
_doing_it_wrong(
// Announced as well as reported, and with the sub-plugin the conflict belongs to. A
// standalone still active after a pass that was supposed to deal with it is the
// failure a host is likeliest to hear about as "the site is broken", and on a
// production site the developer channel says nothing at all.
self::report_error(
self::class,
sprintf(
'The conflict for "%s" threw while being resolved, so it was abandoned: %s',
$sub_plugin->get_slug(),
$thrown->getMessage()
),
'1.0.0'
$sub_plugin
);
}
}
Expand Down
17 changes: 13 additions & 4 deletions src/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Nexcess\PluginAbsorber\Notices\Contracts\Writer_Interface;
use Nexcess\PluginAbsorber\Registry\Reader;
use Nexcess\PluginAbsorber\Traits\Guards_Hook_Prefix;
use Nexcess\PluginAbsorber\Traits\Reports_Errors;
use Throwable;

/**
Expand All @@ -25,6 +26,7 @@
*/
class Loader {
use Guards_Hook_Prefix;
use Reports_Errors;

/**
* @since 1.0.0
Expand Down Expand Up @@ -94,17 +96,21 @@ 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.
try {
$this->load( $sub_plugin );
} catch ( Throwable $thrown ) {
_doing_it_wrong(
self::report_error(
self::class,
sprintf(
'The sub-plugin "%s" threw while loading, so it was abandoned: %s',
$sub_plugin->get_slug(),
$thrown->getMessage()
),
'1.0.0'
$sub_plugin
);
}
}
Expand Down Expand Up @@ -148,14 +154,17 @@ private function load( Sub_Plugin $sub_plugin ): void {
$file = $sub_plugin->get_bundled_plugin_file();

if ( ! is_file( $file ) || ! is_readable( $file ) ) {
_doing_it_wrong(
// 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.
self::report_error(
self::class,
sprintf(
'The bundled plugin file for "%s" is missing or unreadable: %s',
$sub_plugin->get_slug(),
$file
),
'1.0.0'
$sub_plugin
);

return;
Expand Down
11 changes: 9 additions & 2 deletions src/Registry/Reader.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Registry\Contracts\Registrar_Interface;
use Nexcess\PluginAbsorber\Sub_Plugin;
use Nexcess\PluginAbsorber\Traits\Reports_Errors;

/**
* Every registered sub-plugin, as something a pass can be handed rather than reach for.
Expand All @@ -31,6 +32,8 @@
* @since 1.0.0
*/
class Reader {
use Reports_Errors;

/**
* Sub-plugins registered but not yet handed to the registrar.
*
Expand Down Expand Up @@ -162,14 +165,18 @@ protected function flush(): void {
// what became of it is now the consequence -- the site runs one of those two files
// and silently does not run the other. Every other report in this library says what
// the outcome was; this one has to as well.
_doing_it_wrong(
//
// The refused registration is what goes on the action, not the one that stands: a
// listener is being told which object was thrown away, and the one already in the
// registrar is readable from every other path there is.
self::report_error(
self::class,
sprintf(
'%1$s The registration already held was kept; %2$s was discarded.',
$exception->getMessage(),
$sub_plugin->get_bundled_plugin_file()
),
'1.0.0'
$sub_plugin
);
}
}
Expand Down
9 changes: 8 additions & 1 deletion src/Traits/Guards_Hook_Prefix.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@
* @since 1.0.0
*/
trait Guards_Hook_Prefix {
use Reports_Errors;

/**
* Whether a hook prefix has been set, reporting to the developer when it has not.
*
* Reported through the shared channel even though this is the one failure the error action can
* never carry — the prefix is what names that action too, so there is nothing to fire it under.
* The alternative, a bare `_doing_it_wrong()` here, would read as an oversight and would be one
* the moment somebody made the prefix optional.
*
* @since 1.0.0
*
* @return bool
Expand All @@ -33,7 +40,7 @@ private static function has_hook_prefix(): bool {
try {
Config::get_hook_prefix();
} catch ( Config_Exception $exception ) {
_doing_it_wrong( self::class, $exception->getMessage(), '1.0.0' );
self::report_error( self::class, $exception->getMessage() );

return false;
}
Expand Down
Loading
Loading