Skip to content

Pass the plugin's identity as one object, root hooks in its slug and add Assets - #75

Merged
ahegyes merged 23 commits into
trunkfrom
refactor/hook-roots-and-assets
Nov 12, 2026
Merged

ahegyes merged 23 commits into
trunkfrom
refactor/hook-roots-and-assets

Conversation

@ahegyes

@ahegyes ahegyes commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Every hook the framework fired for a plugin took its first level from the storage prefix, or from an id or option name the plugin passed, so a plugin's hooks changed whenever it renamed its stored keys, and the bootstrap updater's filter named the framework's vendor. A plugin also handed the framework its entry file, slug and prefix as loose strings, which a consumer could swap without a type error, and the names the framework stored were arguments that only convention kept under the prefix. Each plugin carried its own copy of the registrars for its wp-scripts build, and the reference fixtures declared their option names, migration steps, footprint, safeguards and boot callbacks as globals that any code could call.

Changes

  • Core's PluginIdentity holds a plugin's entry file, slug and prefix, derives its hook root and refuses a prefix outside the storage pattern. Every framework class that needed one of those strings takes it instead: PluginBoot, AdminNotices, MigrationRunner, Assets, Scheduler, both loggers, CapabilityGrants, Lock, both caches, WooCommerceRequirement::check(), Uninstaller::run(), OptionsPage, ObjectForm and ProductDataTab. The composition root builds it once and defines it in the container.
  • Every hook fires under the slug with underscores for its dashes; the three surfaces pass their id as the filters' last argument. AdminNotices registers its routes under {slug}/v1, and the updater filters {text_domain}/github_updater/config. The framework derives the names it stores from the prefix: the runner's {prefix}_revision, the grants' {prefix}_capabilities, a lock's {prefix}_{name} and a region under the prefix for each cache; every log channel is the slug.
  • Core's Asset\Assets registers a plugin's built scripts and styles from the .asset.php wp-scripts writes, a style-{entry}.css chunk from its entry's, with translations under the slug and the RTL variant; a file wp-scripts did not build is versioned by its modification time as a best effort.
  • consumer-a, consumer-b and the personas keep only contract in the global namespace: functions.php declares the @api accessor and registers its hooks as static closures, and option names, defaults and readers, migration steps and the footprint live in final classes. A component that builds a surface takes the identity by constructor. consumer-a's safeguard is a final Safeguards.php at the plugin root, parsed on PHP 7.4, and its entry wraps everything after it in one try. Conventions 2, 3, 5, 6 and 8 and the README excerpts follow.
  • The WooCommerce floor is 11.3, and the Composer and npm locks are refreshed.
  • The integration tests log the current user out after every row, which fixes an order-dependent failure.

Testing

composer quality-check (validation, PHPCS, PHPStan, Unit and Integration), the multisite and without-woocommerce groups, the Php74 suite, the end-to-end spec, ESLint, both audits, actionlint and zizmor, and the PHP 7.4 parse of consumer-a's by-path files and the bootstrap package, all passing locally.

Refresh the root and fixture Composer locks and the npm lock, and raise
the WooCommerce floor to 11.3, which is now the newest stable release on
wordpress.org, WP Packages and php-stubs.

The floor moves as the CONTRIBUTING checklist asks, in one commit: the
woocommerce package's extra.requires-wc, the root's wp-plugin/woocommerce
and php-stubs/woocommerce-stubs constraints, the fixtures' stubs
constraints and their WC requires at least and WC tested up to headers,
the README's excerpt of the woocommerce persona, and the rows that pin
the manifest's floor and consumer-a's translated floor notice. Every
cache group MetaTypes::DEFINITIONS names is still used in WooCommerce
11.3.0, order_item_meta as the group WordPress derives from the
order_item meta type.

Otherwise the Composer locks take jetbrains/phpstorm-stubs 2026.3,
laravel/serializable-closure 2.2.0 and roave/security-advisories, and
the npm lock @wordpress/env 12.1.0, @wordpress/scripts 37.1.0 and
@wordpress/e2e-test-utils-playwright 3.2.0, all past the seven-day
cooldown. WordPress's core pins stay, since 7.1.4 and 6.3.14 are their
branches' newest patches.

Assisted-by: Claude Code:claude-opus-5-5
The framework names every hook it fires for a plugin after the plugin's
storage prefix, or after an id or option name the plugin passes, so a
plugin's hooks carry the dws_ vendor token that a later owner can
migrate out of its stored keys but never out of other people's code.
The bootstrap updater's filter names the vendor outright.

The rows now build PluginBoot, AdminNotices, MigrationRunner,
OptionsPage, ObjectForm and ProductDataTab with the plugin's slug and
expect each hook under the slug in snake case: {root}/boot/*,
{root}/admin_notice/*, {root}/migration/run and its run_now admin-post
action, and {root}/options_page/tabs, {root}/object_form/fields and
{root}/product_data_tab/groups with the surface's id as their last
argument. AdminNotices' routes sit under {slug}/v1, and the updater's
filter is {root}/github_updater/config. The storage trait's slug differs
from its prefix, so a row tells a hook from a stored key.

The runner's hook no longer comes from its option, so the option's
length is bounded by its longest stored name, {option}_high_water, and
the slug's by the hook; a row covers each bound. The fixtures and the
README excerpts pass their plugin's slug, and consumer-a's own hooks
take its slug as their root.

Assisted-by: Claude Code:claude-opus-5-5
Every hook the framework fired for a plugin took its first level from
the storage prefix, or from an id or option name the plugin passed, so
the hooks other code listens to changed whenever a plugin renamed its
stored keys, and one plugin's hooks read under as many roots as it had
surfaces. The updater's filter named the framework's vendor outright.

PluginBoot, AdminNotices, MigrationRunner, OptionsPage, ObjectForm and
ProductDataTab now take the plugin's slug and fire every hook under it
with underscores for its dashes. The three surfaces pass their id as
the filters' last argument, so a listener for one instance compares
the id. AdminNotices registers its routes under {slug}/v1, and the
updater filters {text domain}/github_updater/config. The prefix, the
option and the ids keep naming what is stored, nonces, script handles
and DOM ids. The slug is not checked at runtime, since a malformed one
only misnames hooks, which the platform tolerates.

The runner's hook no longer comes from its option, so the option is
checked against its longest stored name, {option}_high_water, where
the scheduled hook's own check used to bound it.

Assisted-by: Claude Code:claude-opus-5-5
Each plugin carried its own copy of three registrars that read the
*.asset.php file wp-scripts writes beside a build, differing only in
prefix, so a fix to how a build's dependencies, translations or RTL
stylesheet register had to be made in every copy by hand.

The rows build core's Assets with the core fixture's entry file and a
slug, and expect a script registered with the build's script handles
but not its module imports, its version, its loading arguments and its
translations under the slug from the plugin's languages/ directory; a
style registered with the build's version and its RTL variant; and a
handle another plugin registered first left as it was. The fixture
commits the build's asset file, so no build runs in the tests.

Assisted-by: Claude Code:claude-opus-5-5
The registrars that read a build's *.asset.php lived as prefixed
globals in each plugin, three copies of one WordPress-facing service.

Core's final Assets takes the plugin's entry file and slug. Its
register_script() registers the built script with the build's script
dependencies, dropping script module imports, which are not script
handles, and its version, and sets the script's translations under the
slug from the plugin's languages/ directory; register_style() registers
the stylesheet with its version and its RTL variant. Both report
whether WordPress registered the handle, and a handle registered
elsewhere first gains no translations. A composition root defines it
once in its container, and the core README describes it.

Assisted-by: Claude Code:claude-opus-5-5
Consumer-a's safeguard and its helper were named globals in includes/,
reachable and removable by any code, and its entry reported a build
that could not load through two more globals, also called from its
boot, so a composition root that could not be built showed the build
notice beside the boot boundary's own report.

The harness now finds the safeguard as a static closure that the
Safeguards class declares, at the same priority, and tells that the
safeguards loaded by that class. A missing src/Plugin.php is no longer
an incomplete build that the entry reports, so its build row goes; the
fatal at plugins_loaded that remains is the accepted cost of a partial
deploy that keeps the autoloader but loses a class. The uninstall row
that removed the footprint's include removes src/Options.php, where
the consent option's name lives.

Assisted-by: Claude Code:claude-opus-5-5
Consumer-a and the core persona, the anatomy the template copies,
declared their option names, migration steps, footprint and safeguards
as prefixed globals in includes/, which any code could call and come
to rely on, and consumer-a reported an incomplete build through two
more globals that its boot also called.

Each plugin's settings option and defaults are constants of a final
Options class, which also holds the core persona's greeting reader;
its steps are protected statics of a final Migrations class, handed to
the runner as first-class callables; and its footprint is a constant
of its composition root, which uninstall.php reads after the
autoloader. Consumer-a's safeguard is a static closure of a final
Safeguards class at the plugin root, required by path before the floor
gate and parsed on PHP 7.4, so no code can remove it by name. Its entry
wraps everything after the safeguards in one try, whose catch rethrows
in local and development environments and otherwise hooks the build
notice, and its boot leaves a composition that cannot be built to the
boot boundary. The PHP 7.4 parse and the PHP 7.4 PHPStan profile cover
Safeguards.php in place of includes/safeguards.php, and convention 5
describes the class.

Assisted-by: Claude Code:claude-opus-5-5
The fixtures' functions.php declared the accessor and every hook
callback as named globals without @api, so the global namespace held
wiring that any code could call, unhook or come to rely on, and the
woocommerce persona kept its option name, meta key and footprint as
globals in includes/. Convention 6 still placed option keys, the
footprint, migration steps and asset registrars there as prefixed
globals.

Each accessor stays global and is tagged @api, its contract being that
it returns the composition root, whose members stay internal unless
tagged. The boot, the compatibility declaration and the personas'
settings and product data registrations are static closures in
functions.php. The woocommerce persona's option name moves to an
Options class and its meta key and footprint to Packing, which holds
its slug and prefix, and its includes/ goes. The README excerpts copy
the new code, and convention 6 states where each kind of symbol lives.

Assisted-by: Claude Code:claude-opus-5-5
The scheduler's constructor documented its group as the plugin's hook
prefix, but the framework's hooks are rooted in the plugin's slug, and
the group is the storage prefix that names its Action Scheduler group
and WP-Cron schedules.

Assisted-by: Claude Code:claude-opus-5-5
The post meta box row that fetches a rejection through the block
editor's delivery endpoint logged the administrator in and never logged
them out, so under some random orders a later row that needs nobody
logged in, such as the notices' rejection of a user notice without a
user, ran as the administrator and failed.

The row logs the user out in a finally, as its sibling that saves as
the administrator already does.

Assisted-by: Claude Code:claude-opus-5-5
The runner's scheduled hook and the notices' REST routes are named
after the plugin's slug, so two runners or two notices instances in one
plugin would share them, yet neither class said a plugin builds one.
Limitation 5 still described a template entry whose catch came after
the bootstrap package's require, while the reference entry now requires
it inside its one try.

The runner's and the notices' class docblocks state that a plugin
builds one of each, and limitation 5 describes the entry's catch, which
takes a partial bootstrap package's Error everywhere but on PHP 7.4.

Assisted-by: Claude Code:claude-opus-5-5
The block-editor delivery row's finally fixed one leak, but nothing
stopped the next row that logs a user in from leaking again, and the
rows that need nobody logged in still depended on every earlier row
cleaning up after itself in random order.

The user trait, which every row that logs a user in uses, logs the
current user out after each row, so the delivery row's own finally
goes.

Assisted-by: Claude Code:claude-opus-5-5
Limitation 5 said the entry's catch takes a partial bootstrap
package's Error, but only consumer-a's entry requires load.php inside
its try, while the personas and consumer-b require it outside one and
fatal at include; and it said the uninstall shim keeps the data, which
holds for a web request, while WP-CLI fails and keeps the files.

Assisted-by: Claude Code:claude-opus-5-5
Once the user trait logged the current user out after every row, the
storage, object form and WooCommerce settings tests still did it in
their own teardown, and four programmatic-update rows did it again in
their finally, so three places owned one reset.

Those copies go. The admin-screen trait keeps its own, since it logs
users in itself.

Assisted-by: Claude Code:claude-opus-5-5
Core's Assets required the .asset.php named after each built file, but
wp-scripts writes none for the style-{entry}.css chunk that a style.scss
import becomes, keeping its version in the entry's file, so registering
such a stylesheet threw on every request; a file built outside
wp-scripts failed the same way.

The rows register a committed split stylesheet and a committed script
without asset files, and expect each versioned by its modification
time, the script with no dependencies but the i18n one its
translations add.

Assisted-by: Claude Code:claude-opus-5-5
Assets required the .asset.php named after each built file, so the
style-{entry}.css chunk wp-scripts makes of a style.scss import, whose
version sits in the entry's asset file, threw on every request that
registered it, as did any file built outside wp-scripts.

Where a built file has no asset file of its own, Assets registers it
with its modification time as its version, and a script then with no
dependencies, as v1 versioned every asset. Only wp-scripts builds are
supported; the fallback is a best effort. The core README says so.

Assisted-by: Claude Code:claude-opus-5-5
The modification-time fallback also caught the style-{entry}.css chunk
that wp-scripts makes of a style.scss import, though wp-scripts writes
its version, a hash of its content, into the entry's asset file beside
it, so a supported build's stylesheet was versioned only as a best
effort.

The split stylesheet's row now expects the entry's version. A
stylesheet without any asset file keeps the modification time, and a
script named like a style chunk takes no entry's asset file, since
wp-scripts splits only stylesheets that way.

Assisted-by: Claude Code:claude-opus-5-5
The modification-time fallback versioned the style-{entry}.css chunk
that wp-scripts makes of a style.scss import, though wp-scripts writes
that chunk's version, a hash of its content, into the entry's asset
file beside it, so a supported build got a best-effort version that
changes with every copy of the file.

A stylesheet named style-{entry}.css without an asset file of its own
reads the entry's. Only a file with neither, which wp-scripts did not
build, falls back to its modification time.

Assisted-by: Claude Code:claude-opus-5-5
The asset lookup computed the built file's directory for its own asset
file and again for its entry's.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Refactor/hook roots and assets Root hooks in the plugin's slug, add Assets and move the fixtures' internals into classes Nov 12, 2026
A plugin handed the framework its entry file, slug and prefix as loose
strings in pairs and triples, so a swapped pair, such as the prefix
where the slug belonged, went unnoticed and rooted hooks in the wrong
name, and every class repeated the rule that turns a slug into a hook
root. The names the framework stores, such as the runner's revision
option, the grants' ledger and the scheduler's group, were the plugin's
to choose, though every consumer already built them from its prefix.

The tests and fixtures now pass one PluginIdentity: the storage trait
and the migration fixtures give each row a fresh one, the reference
plugins build theirs once in the composition root and define it in the
container, and components that build a surface take it by constructor.
The rows expect the names the framework derives from the prefix, the
slug as every log channel, and the prefix check in the identity, which
a new unit test covers, while AdminNotices keeps its reserved segments.

Assisted-by: Claude Code:claude-opus-5-5
Every framework class that needed the plugin's entry file, slug or
prefix took them as separate strings, so a consumer could swap two of
them without a type error, the slug-to-hook-root rule lived in six
classes across three packages, and the names the framework stores were
loose arguments that only convention kept under the plugin's prefix.

Core's new PluginIdentity holds the entry file, the slug and the prefix,
derives the hook root and refuses a prefix outside the storage pattern.
PluginBoot, AdminNotices, MigrationRunner, Assets, Scheduler, both
loggers, AbstractLogger's format_record(), CapabilityGrants, Lock, both
caches, WooCommerceRequirement::check(), Uninstaller::run() and the
three surfaces take it. The runner stores {prefix}_revision, the grants
{prefix}_capabilities, a lock {prefix}_{name}, and each cache a region
under the prefix; every log channel is the slug. AdminNotices keeps only
its reserved segments. AGENTS.md, conventions 2, 3 and 8 and the README
excerpts follow.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Root hooks in the plugin's slug, add Assets and move the fixtures' internals into classes Pass the plugin's identity as one object, root hooks in its slug and add Assets Nov 12, 2026
Assets set every stylesheet to be replaced by its -rtl.css on right-to-
left sites, so a stylesheet built without wp-scripts, which writes no
such file, made WordPress request a missing file there and the plugin's
screens lost their styles, where the rest of the best-effort fallback
only degrades.

The fixture build gains editor.css and its editor-rtl.css, as wp-scripts
writes them, and a row expects vendor.css, which has no RTL file, to
register without the swap.

Assisted-by: Claude Code:claude-opus-5-5
register_style() set every stylesheet to be replaced by its -rtl.css on
right-to-left sites, so a stylesheet built without wp-scripts, which has
no such file, loaded nothing there.

The swap is set only when the -rtl.css WordPress would request is
readable beside the stylesheet; one without it keeps its own file on
right-to-left sites, as the rest of the fallback's best effort does.
wp-scripts builds, which always write the variant, are unchanged. The
core README says so.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes
ahegyes merged commit eb1473c into trunk Nov 12, 2026
23 checks passed
@ahegyes
ahegyes deleted the refactor/hook-roots-and-assets branch November 12, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant