Skip to content

Fix the framework's runtime edge cases and tighten its contract - #73

Merged
ahegyes merged 33 commits into
trunkfrom
fix/runtime-behavior-and-contract
Nov 9, 2026
Merged

ahegyes merged 33 commits into
trunkfrom
fix/runtime-behavior-and-contract

Conversation

@ahegyes

@ahegyes ahegyes commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

The port audit found runtime edge cases in every package and gaps in the published contract. The updater misordered or mis-channeled some tags and showed raw release notes; cached objects of a class that no longer loads read as hits; an old Action Scheduler or a switch back to the plugin's own site stopped the scheduler; a long WP-CLI queue run never ended once a migration spent its budget; a line break in a log value could forge a record; uninstall read every id at once, left term relationships and showed WordPress's critical-error page on failure; the object stores accepted values they cannot keep; and settings fields turned a typed < into &lt;, misread checkbox state, ignored disabled product radios and missed ids WooCommerce rewrites. This fixes each as ruled, adds rows for guards no test caught, and gives shapes, messages and helpers one spelling.

Affected packages

  • wp-framework-bootstrap
  • wp-framework-shared
  • wp-framework-core
  • wp-framework-settings
  • wp-framework-woocommerce

Checklist

  • Tests added or updated (Unit and/or Integration as appropriate)
  • composer quality-check passes locally (PHPCS, PHPStan and the tests)
  • Public API changes are documented in the affected package's README.md
  • Breaking changes are explicitly justified below

Breaking changes / migration notes

No release is tagged yet; against trunk, as follows. The READMEs carry the contract changes; the method-level changes are listed here and in AGENTS.md's file map.

  • Removed: PluginKernel::$resolved, public state only tests read; the contract now names public properties, a protected(set) one for reading. Forms::sanitize_value(), which had no caller outside Forms; Forms::sanitize_fields() keeps its rules.
  • Stores: ObjectStoreInterface::update_values() takes scalars, null and arrays of them, and MetadataStore and OrderMetaStore throw InvalidArgumentException before writing when a value holds anything else; a field's validate returns that type or a WP_Error. New: Core\Storage\StoredData::is_storable_data() and ::check_values().
  • WooCommerce settings: SettingsTab and SettingsSection throw InvalidArgumentException for a tab id, section key or section id that sanitize_title() would rewrite.
  • Updater: tags offer only lowercase dev, alpha, beta and rc labels, and the stable channel skips any suffixed tag whatever GitHub's prerelease flag says.
  • Strings: both rejection sentences say "The other changes were saved.", and the migration error notice carries a Run now link while a batched step is pending.

configs' trunk moved to b107001, where Dependabot's bump of
zizmorcore/zizmor-action reaches its reusable workflow checks, while
the framework's workflows, npm pin and locks held c944e73, so the
framework's Workflow Checks ran the older action.

The 17 workflow refs, the npm pin, the root lock and the seven fixture
locks move to configs b107001 together, and the fixture locks take the
path packages' current references. No other Composer package has a
newer release. npm update offered only hoisting changes and a downgrade
of @babel/helper-create-regexp-features-plugin to the release the
host's seven-day cooldown shows, so the npm lock moves only the configs
pin.

Assisted-by: Claude Code:claude-opus-5-5
Audit batches framework-B7, tests-B1 and tests-B2 list guards whose
deletion no row notices: the pre-6.4 fallback of
Requirements::print_admin_notice(), two updater cache and header guards,
GuardedLogger's pass-through, raw error-log record and level check, the
cron-expression branch of ensure_recurring(), a lock released between
the failed insert and the read, a consent row under option_prefixes
after a failed run, the ABSPATH guard after each use block, a subsite
pass beside the main site's footprint, aria-describedby on textarea and
select controls, two tabs sharing one option, the boot-failure
throttle's hour, ErrorLogLogger's production threshold, the README
excerpt check's margin, the packages' extra floors, development-only
names in the closure check, leading zeros past the major and a notice
that has not expired yet.

Each guard gets a row. The bootstrap persona's notice prints through
Requirements::print_admin_notice(), so BootstrapPersonaTest pins the
fallback's markup, and ThresholdLogger, which stood in for
ErrorLogLogger, goes with its file.

Assisted-by: Claude Code:claude-opus-5-5
PHP's version_compare() misorders prerelease labels other than lowercase
dev, alpha, beta and rc; the stable channel offers a suffixed tag whose
prerelease flag was never set on GitHub; the details modal shows release
notes as escaped Markdown and leaves out the author, homepage, release
date and tested-up-to version; and a refused plugin outside the plugins
directory raises a warning on every admin screen, which its row pins.

The rows expect uppercase and mixed-case labels skipped, an unflagged
prerelease kept off the stable channel, GitHub's rendered body and the
plugin's headers in the details, Tested up to in the offer, and no
warning. The channel-switch row starts from a stable release, which the
stable channel still offers.

Assisted-by: Claude Code:claude-opus-5-5
When load.php exists but a class file it requires is missing, the
require fatals at include, before the entry can return, and the
uninstall shim cannot catch it on PHP 7.4. consumer-a's partial-build
rows cover a missing load.php but no missing class.

A row removes the bootstrap package's Updater.php and expects what a
missing load.php gives: the build notice, a working site and the
safeguard in place.

Assisted-by: Claude Code:claude-opus-5-5
An entry holding an object or enum case whose class no longer loads, as
after an update renames it, unserializes to __PHP_Incomplete_Class or
fails, and returns as a hit.

The rows plant such entries, at the top, nested in an array or an object
and as an enum case, in both caches and expect a miss from get(), has()
and getMultiple().

Assisted-by: Claude Code:claude-opus-5-5
An Action Scheduler older than 3.5.5 never fires action_scheduler_init,
so the scheduler waits for it on every request and never falls back to
WP-Cron; and a switch back to the site the plugin loaded on refuses
every scheduler call.

The rows expect WP-Cron once init has passed without Action Scheduler's
init, and scheduling inside a switch to the plugin's own site. Rows that
stand before Action Scheduler's init run inside init, and the group and
hook-name rows move from Unit to Integration, because the scheduler
reads the current site when it is built.

Assisted-by: Claude Code:claude-opus-5-5
Out of budget inside its own hook, the runner schedules a continuation
that a long wp action-scheduler run keeps claiming, so the process never
ends; and the notice for a failed batched step drops the Run now link,
its only manual recovery.

The rows expect nothing of the runner due for the queue run in progress
and the next request to resume, and a failed batch's notice to link Run
now, which completes the repaired step.

Assisted-by: Claude Code:claude-opus-5-5
A value interpolated into a log message can carry a line break, which
writes a forged record into PHP's error log.

The shared message rows expect \r and \n escaped in an interpolated
value.

Assisted-by: Claude Code:claude-opus-5-5
The uninstall engine reads every id of a key into memory before it
deletes any, so a key on a few hundred thousand rows never clears from
the web; deleting the posts of an unregistered post type leaves their
term relationships and stale term counts; and a web uninstall whose
engine fails ends in WordPress's critical-error page, while its reason
reaches only the error log.

The rows expect the ids of a key on more than 1,000 rows read in pages,
on a site and in the network pass; a post's term relationships deleted
with it, while a post whose delete another plugin refuses keeps its own
and a user under the same id keeps those of a user taxonomy; and
consumer-a's failing web uninstall to answer 500 with the engine's
message, keeping the files and the data. The request harness gains a
spec key that fails every query holding a given text.

Assisted-by: Claude Code:claude-opus-5-5
MetadataStore and OrderMetaStore accept any value, but the metadata API
rewrites an object's public properties as it unslashes them, and an
object's class name changes with each plugin's scoping prefix, so it
would not read back.

The rows expect an object, at the top or nested in an array, to throw
before any key is written, in post meta and in both of WooCommerce's
order storages.

Assisted-by: Claude Code:claude-opus-5-5
Nested equality calls a value object's equals() with an object of any
class, while equals() admits only its own class, so a lenient
implementation reports two different objects as equal.

The rows expect sibling classes with equal values, a lenient object
beside another class, an integer beside a numeric string and distinct
objects of equal state to compare unequal, and one object held by both
to compare equal.

Assisted-by: Claude Code:claude-opus-5-5
A lone '<' typed into a text or textarea field comes back as a visible
'&lt;' after the save, because the sanitizer encodes it and every output
escapes it again; a checkbox renders checked only for 'yes', while its
sanitizer and convention 8's typed read also treat true and '1' as on;
and the rejection sentence does not say that the other changes were
saved (X ruling 334).

The rows expect '<' kept and a typed '&lt;' read as text, a checkbox
checked for true and '1', and the new sentence on every host. The
product panel's entity row stores a value without '&lt;', which a save
now turns back into '<'.

Assisted-by: Claude Code:claude-opus-5-5
Product-panel radios render through woocommerce_wp_radio(), which
ignores disabled, so a disabled radio drops the user's change and a
disabled stored choice is rejected on every save; and WooCommerce passes
tab and section ids through sanitize_title(), so a rewritten id leaves
the fields unclaimed and their validate bypassed.

The rows expect a disabled radio and a disabled choice rendered disabled
and kept, and an id that sanitize_title() rewrites rejected as a tab id,
a section key and a section id. The panel's textarea, radio, email and
url fields also render and keep entities and quotes.

Assisted-by: Claude Code:claude-opus-5-5
The prerelease pattern's /i flag admitted labels that version_compare()
misorders; the stable channel trusted GitHub's prerelease flag, which is
set by hand; the details modal escaped the release's Markdown and left
out the author, homepage, release date and tested-up-to version; the
cached record kept an unused tag; and a refused plugin outside the
plugins directory made core warn on every admin screen (X ruling 167,
and X ruling 294's A762).

The pattern takes only lowercase dev, alpha, beta and rc, and the stable
channel skips any tag holding '-'. The release request asks for GitHub's
HTML media type, and the details carry its body_html, which core's kses
filters, with the plugin's Author and Plugin URI, the release date and
the entry's Tested up to header, which the offer carries too. The cached
record drops its tag, and a refused plugin's notice returns before core
reads headers from a file it cannot find.

Assisted-by: Claude Code:claude-opus-5-5
With load.php present but one of its class files missing, its require
fataled at include, and PHP 7.4 cannot catch a failed require, so the
plugin took the site down (X ruling 195's A216).

load.php checks both class files before it requires either and returns
false when one is missing, and the entries and README excerpts return on
false as they do on a missing load.php. Limitation 5 drops its
partial-bootstrap sentence, and the contract names load.php's return
value.

Assisted-by: Claude Code:claude-opus-5-5
An entry whose serialized object or enum names a class that no longer
loads returned as a hit holding __PHP_Incomplete_Class, or failed to
unserialize (X ruling 119).

unwrap() reads a serialized entry naming any class that does not load as
a miss before unserializing it, and AGENTS.md drops the advice to clear
caches when cached classes change.

Assisted-by: Claude Code:claude-opus-5-5
An Action Scheduler older than 3.5.5 never fires action_scheduler_init,
so get_state() reported Initializing on every request (X ruling 120);
and check_ready() refused every switched site, the plugin's own included
(X ruling 168's A19).

After init, an Action Scheduler that has not initialized is Unsupported,
so the scheduler falls back to WP-Cron. The scheduler keeps the id of
the site it was built on and refuses only another site.

Assisted-by: Claude Code:claude-opus-5-5
Out of budget inside its own hook, the runner scheduled a one-second
continuation, which a long wp action-scheduler run kept claiming, so the
process never ended (X ruling 137); the notice for a failed batched step
lost the Run now link, its only manual recovery (X ruling 121); and
wp_cache_flush_runtime() ran without asking whether the object cache
supports it (X ruling 168's A17).

The continuation is an enqueue, which the running invocation satisfies,
so the next request or process continues the migration; the error notice
links Run now while a batched step is pending; and the flush runs only
where wp_cache_supports( 'flush_runtime' ). Limitation 2 says how a
persisting batched failure and a long queue run behave.

Assisted-by: Claude Code:claude-opus-5-5
A line break in an interpolated value could forge a record in PHP's
error log (X ruling 168's A20).

interpolate() writes \r and \n in a value as their escapes; the
exception block keeps its lines.

Assisted-by: Claude Code:claude-opus-5-5
The engine read every id of a key at once, so a key on a few hundred
thousand rows never cleared from the web; a post deleted while its type
is unregistered kept its term relationships and term counts; and
consumer-a's uninstall.php let the engine's exception reach WordPress's
critical-error page, so its reason, such as the large-network refusal
naming wp plugin uninstall, reached only the error log (X ruling 169).

Id reads go in pages of 1,000, still deleting only matched ids. Once
wp_delete_post() has deleted a post,
wp_delete_object_term_relationships() removes its relationships to every
registered taxonomy of a post type, so a post whose delete another
plugin refuses holds on to its terms, and a link or a user under the
same id keeps its own. consumer-a's uninstall.php catches the engine's
RuntimeException, rethrows it under WP-CLI and otherwise ends in
wp_die() with the escaped message and a 500, before WordPress deletes
the files. Limitation 3 says that exact names match under the database
collation and that the large-network bound counts the current network's
sites.

Assisted-by: Claude Code:claude-opus-5-5
MetadataStore and OrderMetaStore accepted any value, but the metadata
API rewrites an object's public properties as it unslashes them, and an
object's scoped class name would not read back (X rulings 135, 332 and
336).

Core's StoredData holds the rule: is_storable_data() for OptionRows, and
check_values(), which both object stores call before they store any
value and which throws otherwise. The store interface, the validate
callables and sanitize_fields() type their values that way, and
WooCommerce's pass-through keeps a value only when it is storable.

Assisted-by: Claude Code:claude-opus-5-5
Nested equality called a value object's equals() with an object of
another class, which that method's contract does not admit; and
ExceptionInterface claimed every framework exception, while PSR-3 makes
the logger throw its own (X ruling 170).

Nested equality checks the classes before calling equals(), and
ExceptionInterface's docblock says it marks the exceptions the framework
defines.

Assisted-by: Claude Code:claude-opus-5-5
The text sanitizers encode a lone '<', which every output escapes again,
so the user saw '&lt;' after the save (X ruling 171's A33, option a); a
checkbox rendered checked only for 'yes' while its sanitizer reads true
and '1' as on (A35); and the rejection sentence left out that the other
changes were saved (X ruling 334).

The text arms turn the sanitizer's '&lt;' back into '<', a checkbox
renders checked through sanitize_checkbox(), and both rejection
sentences say that the other changes were saved.

Assisted-by: Claude Code:claude-opus-5-5
woocommerce_wp_radio() ignores disabled, so a disabled radio dropped the
user's change and a disabled stored choice was rejected on every save;
WooCommerce passes tab and section ids through sanitize_title(), so a
rewritten id left the fields unclaimed and their validate bypassed; and
WooCommerceLogger's docblock did not say which threshold its fallback
follows (X ruling 171's A36 to A38).

Radios render in the panel's fieldset row through
Forms::render_control(), SettingsSection and SettingsTab throw when
sanitize_title() would rewrite an id or section key, and the logger's
docblock says the fallback follows WooCommerce's logging settings.

Assisted-by: Claude Code:claude-opus-5-5
PluginKernel::$resolved was public state that only tests read, and the
published contract left public properties out (X ruling 155).

$resolved goes, the consumer rows check the skipped classes instead, and
the contract in AGENTS.md and every README names properties, a
protected(set) one for reading by its name and declared type.

Assisted-by: Claude Code:claude-opus-5-5
SettingsSection inherits WooCommerce's experimental settings API, which
may change in any release (X ruling 116); the post editor shows a
notice's markup through core/notices' __unstableHTML (X ruling 294's
A763); and a capability grant reaches the current user only from the
next request (X ruling 168's A21).

The contract in AGENTS.md and the woocommerce README, limitation 7 and
limitation 8 say so.

Assisted-by: Claude Code:claude-opus-5-5
The updater spelled its two shapes twice each, three migration messages
and a comment hard-coded the lock's ten minutes, the cache-key message
repeated the reserved characters, the notice handles and DOM id were
spelled at each use, OptionRows hid its name check in serialize(),
Fields took a map whose values it never read, and three hosts imported a
type they never use (X ruling 179's A112 to A116, A120 and A121).

Each has one spelling: Config and Release aliases, LOCK_TTL,
RESERVED_CHARACTERS, one handle per script method and get_dom_id(), a
name check in insert() as in get(), a list of section ids, and no unused
import.

Assisted-by: Claude Code:claude-opus-5-5
The footprint was cleaned at 15 read sites, while the meta types were
cleaned once in run() (X ruling 179's A117).

run() cleans every kind once and hands the engine lists of names, with
the meta kinds keyed by the meta types it knows.

Assisted-by: Claude Code:claude-opus-5-5
sanitize_value() was public API with no caller outside Forms (X ruling
179's A118).

Its body moves into sanitize_fields(), its rules into that method's
docblock, and its rows into FormsSanitizeFieldsTest through one helper.

Assisted-by: Claude Code:claude-opus-5-5
FieldRow typed its field as any Field, so OptionsPage and ObjectForm
read the id and title through ?? '' fallbacks, although get_rows() keeps
only fields with an id and sets each one's title (X ruling 179's A119).

FieldRow's field carries its id and title, so both hosts read them as
they are. FieldRow keeps WordPress's label_for?: string, set only for a
field that is not a group, because OptionsPage hands each row to
add_settings_field() as it is, and ObjectForm maps it once onto the
host's Row (X ruling 335).

Assisted-by: Claude Code:claude-opus-5-5
render_field() printed the fieldset-and-legend wrapper in its render
branch and again for radios and checkboxes (X ruling 179's A122).

Radios and checkboxes join the render branch, which hands Forms the
field without its own legend.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Fix/runtime behavior and contract Fix the framework's runtime edge cases and tighten its contract Nov 9, 2026
load.php checked its two class files and returned false when one was
missing, a guard for a torn copy of the bootstrap package, which is rare
and heals once the copy completes, while a missing build already fails
the entry's check on load.php. The guard also put load.php's return
value in the 2.x contract (X ruling 337, reversing X ruling 195's A216).

load.php requires both classes as it did, the entries and README
excerpts require it after their readability check, the contract names
only its path, limitation 5 keeps its partial-bootstrap sentence, and
the build row for a missing bootstrap class goes.

Assisted-by: Claude Code:claude-opus-5-5
check_floors() resolved the plugin's basename before it hooked the
notice, although only the notice reads it, so a request that renders no
notice resolved it for nothing (X ruling 337).

The notice callback resolves the basename itself.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes
ahegyes merged commit 4ca91c1 into trunk Nov 9, 2026
32 checks passed
@ahegyes
ahegyes deleted the fix/runtime-behavior-and-contract branch November 9, 2026 20:06
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