Repository navigation
Pass the plugin's identity as one object, root hooks in its slug and add Assets - #75
Merged
Merged
Conversation
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
PluginIdentityholds 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,ObjectFormandProductDataTab. The composition root builds it once and defines it in the container.AdminNoticesregisters 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.Asset\Assetsregisters a plugin's built scripts and styles from the.asset.phpwp-scripts writes, astyle-{entry}.csschunk 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.functions.phpdeclares the@apiaccessor 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 finalSafeguards.phpat 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.Testing
composer quality-check(validation, PHPCS, PHPStan, Unit and Integration), the multisite and without-woocommerce groups, thePhp74suite, 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.