Skip to content

Pass the plugin's identity and keep only contract in the global namespace - #34

Merged
ahegyes merged 19 commits into
trunkfrom
refactor/plugin-anatomy
Nov 14, 2026
Merged

ahegyes merged 19 commits into
trunkfrom
refactor/plugin-anatomy

Conversation

@ahegyes

@ahegyes ahegyes commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

The template declared its option keys, defaults and readers, its migration steps, its footprint, its asset registrars, its safeguards and its boot callbacks as prefixed globals, which any code could call or remove by name, and handed the framework its entry file, slug and prefix as loose strings. Its own hooks took their root from the storage prefix, while the framework now roots every hook in the slug, and the framework it locked predated both.

Changes

  • The framework moves to its eb1473c split, with WooCommerce 11.3 as its floor and tested version. The composition root builds one PluginIdentity from SLUG and PREFIX and passes it to the logger, the scheduler, the boot boundary, the WooCommerce check, the migration runner, the admin notices and the assets; uninstall.php builds its own once the engine is present.
  • The global namespace holds only contract: functions.php declares the @api accessor and registers the boot, deactivation and order-tables hooks as static closures, and includes/ loads only the @api peer getters. A Unit test expects every global function and constant there to carry @api.
  • Options names the stored settings and their defaults and reads them through generic, untranslated readers; Migrations lists its steps; Plugin::FOOTPRINT holds the footprint. The greeting's component owns its translated default.
  • The safeguards are a final class in Safeguards.php, an empty slot the recipe fills with static closures. The entry handles a missing bootstrap package, build tree or required file in one try, whose catch rethrows in local and development environments and otherwise shows the build notice.
  • Core's Assets registers the built scripts and styles, in place of the template's own registrars.
  • The template's own hooks are rooted in its slug, dws_plugin_template/, which the fill-in sweeps.
  • The entry keeps Tested up to, which the bundled updater reads for each offer, held to the locked WordPress by a Unit row.
  • The fill-in accepts a prefix segment of up to 28 characters, the most the framework's identity takes, and Quality rehearses at that cap.
  • A Unit test checks that every directory carries the silence-is-golden stub, and a fresh request in the Integration tests fails on any output before its last line.
  • The editor spec clicks the Outlined style as a radio or a button, as WordPress 7.2 and 7.1 render it.
  • The docs and recipes describe the new anatomy.

Testing

Composer validation, the changelog check, PHPCS, PHPStan, Unit and Integration, the multisite group, ESLint, Stylelint, the type check, the end-to-end suite on WordPress 7.1.4 and the editor spec on 7.2-beta5, the fill-in rehearsal at the longest prefix and at dws_ao, the PHP 7.4 parse of the entry, Safeguards.php and uninstall.php, actionlint, zizmor and both audits, all passing locally.

The template locked the framework from before its plugin identity and
slug-rooted hooks, and WooCommerce 11.2, while WooCommerce 11.3.0 is
the newest stable.

The Composer lock moves the framework to its eb1473c split, WooCommerce
and its stubs to 11.3.0, PHPUnit to 13.4.7 and phpstorm-stubs to
2026.3; the npm lock takes Playwright 1.65.0 and the other releases
past npm's seven-day cooldown, keeping the two transitive packages
whose newer versions the cooldown hides. The entry's WooCommerce
headers and constraints rise to 11.3 (X ruling 374).

The framework move forces these changes: the composition root builds
one PluginIdentity, hands it to the logger, the scheduler, the boot
boundary, the WooCommerce check, the migration runner and the notices,
and defines it in the container; uninstall.php builds its own after the
engine's check; the runner derives dws_tpl_revision itself, so the
revision constant goes and the footprint names its hook,
dws_plugin_template/migration/run; the tests, the safeguard recipe and
the architecture document read the framework's hooks under the slug;
and the fill-in sweeps that root and refuses it as a leftover.

Assisted-by: Claude Code:claude-opus-5-5
The template held no check that every shipped directory carries the
silence-is-golden stub, and its fresh-request helper ignored any output
before a row's last line, so a warning or notice a request printed went
unnoticed. The plugins wrote both first, in the form the template takes
(X ruling 371).

DirectoryStubsTest and RunsRequestsTrait are copied from the plugins
unchanged but for the namespace. The rows whose fixtures provoke a
diagnostic, a missing file's warning, the build error's log line or a
boot listener's throw, name it through request_provoking(). WooCommerce
queries two of its tables on a new network site before installing them,
so MultisiteTest first sends that site a request without the plugin, as
the plugins' row does (X ruling 385).

Assisted-by: Claude Code:claude-opus-5-5
X ruling 373 had the template drop its entry's Tested up to as the
plugins did, because Plugin Check refuses the header beside a
readme.txt. The template has no readme.txt, so Plugin Check never
reaches that check, while the bundled updater reads the header at the
release tag for each update offer; without it, WordPress's Updates
screen reads "Not tested" for every release (X ruling 407).

The entry keeps the header, and an EntryHeadersTest row holds it to the
locked WordPress core's major and minor, as the WC tested up to row
does for WooCommerce. The wordpress.org recipe's first step deletes the
header and its row along with Update URI.

Assisted-by: Claude Code:claude-opus-5-5
The framework roots every hook it fires for a plugin in the plugin's
slug, which survives a change of owner, while the template's recipes
and its AGENTS.md still named the hooks a plugin fires itself under
the dws_tpl storage prefix, and its README and the fill-in workflow's
inputs gave the prefix as the root of every hook (X ruling 377).

The recipes' safeguard filter, its relaxation and the hook-renaming
example, and AGENTS.md's hook-name rule, read dws_plugin_template/,
which the fill-in sweeps to the new plugin's slug in snake case. The
README and the workflow's inputs name the slug as the hook root and the
prefix as that of the globals and the stored data.

Assisted-by: Claude Code:claude-opus-5-5
The template's safeguards are an include file that registers named
global callbacks, which any code can remove by name, and its entry
reports an incomplete build through two global helpers, one of which
logs the error once an hour, while functions.php catches a composition
root that cannot be built (X rulings 393 and 394).

PluginBootTest expects the entry to require Safeguards.php before the
bootstrap package, and drops the boot-listener row, whose catch goes.
PartialDeployTest finds the entry's notice among the admin notices'
callbacks and checks, for a missing bootstrap package, blocks manifest,
assets build or autoloader and for an include that throws, that nothing
boots, that a plugin manager sees the notice and a visitor does not,
and that no log line is printed; the log and deactivation rows go.
SafeguardRecipeTest loads the recipe's Safeguards class under another
name and removes its safeguard by the closure's class.

Assisted-by: Claude Code:claude-opus-5-5
A named global safeguard callback can be removed with remove_filter(),
which defeats a restriction meant to relax only through its own filter,
and the entry's two build-error helpers, the hourly log line and
functions.php's catches around the composition root added globals and
code for failures the entry already sees (X rulings 393 and 394).

Safeguards.php at the plugin root is a final class the entry requires
by path before the floor gate and calls once, an empty slot whose
recipe adds static-closure safeguards and the protected relaxation
helper. The entry wraps the bootstrap, the floor gate, the build check,
the autoloader and functions.php in one try, whose catch rethrows in
local and development environments and otherwise hooks one
static-closure notice. functions.php boots, declares the WooCommerce
compatibility and unschedules on deactivation from static closures
without catches, leaving the composition's failures to PluginBoot.
includes/safeguards.php goes; the safeguards' PHPStan profile, Quality
and the fill-in parse Safeguards.php on PHP 7.4 in its place, and the
profile no longer ignores wp_get_environment_type(), which the catch
guards with function_exists().

Assisted-by: Claude Code:claude-opus-5-5
The template's option keys, defaults, typed readers, translated text and
validator, its migration steps and its footprint are prefixed globals in
includes/, which any code can call and come to rely on, and its string
reader returns the greeting's translated default, mixing presentation
into the data read (X rulings 391, 392 and 408).

The tests read the keys, defaults and readers from Options, the steps
from Migrations::get_steps() and the footprint from Plugin::FOOTPRINT,
which FootprintTest checks against Options' constants. A missing
greeting reads as an empty string, untranslated, while the account
dashboard still prints the translated default. The settings field's
placeholder is the greeting's default message, and the validator's rows
run through the field's validate callable.

Assisted-by: Claude Code:claude-opus-5-5
The option keys, defaults, readers, translated text and validator, the
migration steps and the footprint were prefixed globals in includes/,
so any code could call them and come to depend on them, and the string
reader returned the greeting's translated default, mixing what the
greeting shows into the data read (X rulings 391, 392 and 408).

Options names the stored settings and their defaults as constants and
reads each as its type through generic, untranslated statics.
Migrations lists its protected static steps as first-class callables,
and the composition root holds the footprint as a constant, which
deactivation and uninstall.php read. The greeting's component owns its
translated default message, which it prints for an empty stored
message and the settings field shows as its placeholder; the validator
is a protected static of Settings, handed to its field. The three
includes go, and the Unit suite loads the classes through the
autoloader.

Assisted-by: Claude Code:claude-opus-5-5
The template carried its own registrars for a wp-scripts build, global
functions every plugin copied, while core's Assets registers a plugin's
built scripts and styles from the same asset files, falls back to the
file's time where none exists and swaps in a right-to-left stylesheet
only where its file exists (X rulings 395, 398, 399 and 406).

The composition root defines Assets over the plugin's identity, and
Blocks and the greeting's component take it by constructor. The include
and its test go, with their fixture, since core's suite covers Assets.

Assisted-by: Claude Code:claude-opus-5-5
Any code can call a global function or read a global constant, so a
plugin built from the template comes to support whatever it declares
there, while functions.php still declared the accessor without @api
(X rulings 384 and 397).

GlobalsTest reads functions.php and every file in includes/ and expects
each top-level function and constant they declare to carry @api.

Assisted-by: Claude Code:claude-opus-5-5
The accessor is the one global through which other code reaches the
plugin, yet carried no @api, so nothing told an integration that it may
rely on the function while the composition root it returns stays
internal (X ruling 397).

dws_tpl_plugin() carries @api, and its docblock states that only the
function is contract and the root's members stay internal unless
tagged.

Assisted-by: Claude Code:claude-opus-5-5
The nightly on WordPress trunk timed out clicking the example block's
Outlined style, since WordPress 7.2 renders block styles as radios in a
Styles radiogroup, while 7.1, the floor CI also tests, renders them as
buttons (X ruling 371).

The editor spec clicks the Outlined radio, or the Outlined button where
none exists, within the Editor settings region.

Assisted-by: Claude Code:claude-opus-5-5
The agent guide, the architecture, the scaffold notes, the test guide
and every recipe still named the global helpers and constants that
moved into Safeguards, Options, Migrations and Plugin::FOOTPRINT, the
dws_tpl hook names that moved under the slug, and the framework
constructors that now take the plugin's identity, so a plugin following
them would rebuild the anatomy the template left.

Describe the entry's one catch, the static-closure hooks, the class
homes of options, migrations and the footprint, core's Assets and the
identity, and port each recipe's code to them.

Assisted-by: Claude Code:claude-opus-5-5
Quality rehearsed the fill-in at the longest prefix the script accepted
and checked it against PHPCS alone, while the framework's PluginIdentity
refuses a prefix longer than 32 characters. No step of the rehearsal
built an identity, so a script that accepted more than the framework
would seed a plugin that throws when it boots or uninstalls.

Expect the plugin's slug and prefix to make an identity the framework
accepts, and rehearse at a 28-character segment, the longest the
identity leaves after dws_, which the script's cap of 22 still refuses.

Assisted-by: Claude Code:claude-opus-5-5
The fill-in capped the prefix's segment at 22 characters, because a
longer one pushed the template's longest option constants past PHPCS's
alignment. Those names are now class constants built from
Plugin::PREFIX, so the bound that holds is the framework's
PluginIdentity, which takes a prefix of at most 32 characters.

Accept a segment of up to 28 characters, the most that identity leaves
after dws_, and give its bound as the reason.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Refactor/plugin anatomy Pass the plugin's identity and keep only contract in the global namespace Nov 13, 2026
Another plugin that needs one of the template's settings can only read
the stored option by its key, and the template shows no function a
plugin would offer for that, although an integration may need one
(X rulings 384, 391 and 413).

GreetingFeatureTest expects dws_tpl_is_greeting_enabled() to report
the stored toggle, on, off and unset, as the greeting's gate reads it.

Assisted-by: Claude Code:claude-opus-5-5
The template gave another plugin no way to read one of its settings
but the stored option itself, and showed no function of the kind a
plugin offers when an integration needs a setting (X rulings 384, 391
and 413).

includes/greeting.php declares dws_tpl_is_greeting_enabled(), tagged
@api, which reads the toggle through Options, as the greeting's gate
does, and adds no filter of its own.

Assisted-by: Claude Code:claude-opus-5-5
The recipes showed no way to offer a setting to another plugin, the
architecture, the scaffold notes and the agent guide listed only the
peer getters in includes/, and the add-on convention named only those
getters, so a plugin built from the template would neither find the
new function nor know to delete it with the greeting (X ruling 413).

A recipe adds one narrowly named, typed @api function per setting an
integration needs, delegating to Options; the add-on convention reads a
setting through such a function, else through the base plugin's
options; the architecture and AGENTS.md's layout name
includes/greeting.php, and the scaffold README and the
without-WooCommerce recipe delete it with the greeting.

Assisted-by: Claude Code:claude-opus-5-5
The option keys and the network option the footprint names were built
from Plugin::PREFIX, so no stored name appeared whole in the code and a
search for a contract string found nothing, while one edit to the
prefix silently renamed every stored key (X ruling 414).

Options' keys and the footprint's network option hold their whole
names, as do the recipes' option keys and their two meta keys, which
become constants on the classes that store them; the footprint's
prefixes stay built from the prefix. FootprintTest already fails a key
outside the prefix family.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes
ahegyes merged commit d8965cc into trunk Nov 14, 2026
38 checks passed
@ahegyes
ahegyes deleted the refactor/plugin-anatomy branch November 14, 2026 00:13
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