Skip to content
Closed
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
53 changes: 33 additions & 20 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 @@ -230,12 +230,14 @@ whenever the host's bootstrap happens to run it. This is also why `Absorber::reg
resolves nothing — registration at plugin-file scope is a shape a host is entitled to use, and it
would otherwise register into the throwaway.

**A duplicate slug is `Registry\Registrar::register()`'s exception, not `Absorber::register()`'s.** What
**A duplicate slug is `Registry\Registrar::register()`'s refusal, not `Absorber::register()`'s.** What
`Absorber::register()` throws is config validation, from the `Sub_Plugin` constructor, in the call
the host can see in its own stack trace. The buffer reaches the registrar at the first read —
`plugins_loaded` priority 5 on a request that passes the gatekeeper, priority 6 otherwise — so the
collision surfaces from inside a core action. Both are `Config_Exception`; only one of them can name
the line the host wrote.
the host can see in its own stack trace. A collision cannot be found there: the buffer reaches the
registrar at the first read — `plugins_loaded` priority 5 on a request that passes the gatekeeper,
priority 6 otherwise — long after both `register()` calls returned. So the registrar throws, and
`Registry\Reader::flush()` catches it per entry and reports it through `_doing_it_wrong()` naming the
registration that was discarded. The first registration under the slug stands, the second is dropped,
and everything registered behind it still reaches the registrar.

**The too-late barrier measures against the first step in the sequence, not the last.**
`Boot\Scheduler` compares the priority `plugins_loaded` is already dispatching against the lowest
Expand Down Expand Up @@ -284,12 +286,15 @@ constructed with rather than through the registrar they could resolve for themse
drains the pending registrations before it reads and a registrar asked directly would miss anything
registered since the last flush.

**Both passes also catch `Config_Exception` around that read.** A duplicate slug is only found when
the buffer reaches the registrar, which is a read — long after both `register()` calls returned — and
it arrives inside `plugins_loaded`, the hook that exists to prevent a fatal, so this is the last place
allowed to cause one. The conflict pass needs the guard more than the load pass, not less: its request
gate means the only requests reaching it are admin page views, so an escaping throw lands on exactly
the screens the mistaken registration would have to be corrected from.
**Neither pass guards that read, because the read no longer raises.** The one exception it used to
carry was the duplicate slug, and that is now refused and reported inside `Registry\Reader::flush()`,
where it is found. A guard at the read was the wrong altitude for it: the first pass to read caught
it and stood down whole — the load pass loading nothing at all on the front end, the conflict pass
resolving nothing in wp-admin — over a registry that was intact and readable the entire time. One
mistaken registration is one sub-plugin's problem and the sub-plugins around it still have to load.
What remains are the backstops that were always the right altitude for an unexpected throw: the
`Throwable` catch on each `plugins_loaded` step in `Boot\Scheduler`, and the per-sub-plugin catch
inside `Loader::load_all()` and `Conflict\Resolver::resolve_all()`.

The container is no longer the other half of that. A pass is handed a reader that already holds its
registrar, so a container that cannot supply one fails while the *pass* is being built — where an
Expand Down Expand Up @@ -398,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 @@ -519,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 @@ -606,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 @@ -754,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.
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ container that was never taught about this library. Set the container once, befo
Sub-plugins load in **registration order**, so register a dependency before anything that
extends it at include time, and register each slug exactly once. A config array the library
cannot use throws `Config_Exception` on the spot, in the call you can see in your own stack
trace; a duplicate slug is the exception that surfaces later, on `plugins_loaded`, since
registrations are buffered until the first read.
trace. A duplicate slug is found later, at the first read of the registry — normally on
`plugins_loaded` — since registrations are buffered until then: it is refused there and reported
through `_doing_it_wrong()`, the first registration under the slug stands, and the second is
discarded.

Register unconditionally and put anything you cannot decide up front — a licence, a setting the
site owner can change — in `enabled`, which is re-evaluated on every load. See
Expand Down
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
8 changes: 5 additions & 3 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,11 @@ foreach ( $sub_plugins as $slug => $constant ) {
```

An entry the library cannot use throws `Config_Exception` out of the `Absorber::register()` call it
is in, so a typo names itself in a stack trace pointing at your loop. A duplicate `slug` surfaces
later: registrations are buffered, and the collision is raised at the first read on
`plugins_loaded`.
is in, so a typo names itself in a stack trace pointing at your loop. A duplicate `slug` is found
later, at the first read of the registry — normally on `plugins_loaded` — because registrations are
buffered until then. It is refused there and reported through `_doing_it_wrong()`: the first
registration under the slug stands, the second is discarded, and every other sub-plugin loads as
normal.

## Choose a policy, and know what the site owner sees

Expand Down
15 changes: 7 additions & 8 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 @@ -111,8 +113,7 @@ public static function register( array $config ): void {
*
* @since 1.0.0
*
* @throws Config_Exception When no container has been set, or two sub-plugins were registered
* under one slug.
* @throws Config_Exception When no container has been set, or its binding is unusable.
*
* @return array<string,Sub_Plugin>
*/
Expand Down Expand Up @@ -181,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 @@ -216,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
Loading
Loading