Repository navigation
Fix the framework's runtime edge cases and tighten its contract - #73
Merged
Merged
Conversation
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 '<' 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 '<' 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 '<', 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 '<' 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 '<' 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
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
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.
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<, 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-bootstrapwp-framework-sharedwp-framework-corewp-framework-settingswp-framework-woocommerceChecklist
composer quality-checkpasses locally (PHPCS, PHPStan and the tests)README.mdBreaking 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.
PluginKernel::$resolved, public state only tests read; the contract now names public properties, aprotected(set)one for reading.Forms::sanitize_value(), which had no caller outsideForms;Forms::sanitize_fields()keeps its rules.ObjectStoreInterface::update_values()takes scalars, null and arrays of them, andMetadataStoreandOrderMetaStorethrowInvalidArgumentExceptionbefore writing when a value holds anything else; a field'svalidatereturns that type or aWP_Error. New:Core\Storage\StoredData::is_storable_data()and::check_values().SettingsTabandSettingsSectionthrowInvalidArgumentExceptionfor a tab id, section key or section id thatsanitize_title()would rewrite.dev,alpha,betaandrclabels, and the stable channel skips any suffixed tag whatever GitHub's prerelease flag says.