The actions run on Node 24, and Package moves to one shared job actions/upload-artifact@v4 and actions/checkout@v4 declare node20, which the runners no longer provide -- GitHub was already forcing them onto 24 and saying so in an annotation. ci.yml had moved checkout to v7 and the two Claude workflows had not, so this repository was running two majors of one action. Nothing between v4 and v7 of either touches how they are used here: the inputs in use are unchanged, and v7 of checkout restricts only fork pull requests under pull_request_target and workflow_run, neither of which any workflow here uses. Package is a caller now. The job it names builds the zip a release would have built -- the build where there is one, the deploy's exclusion list, the readme transform -- and lives in the tooling repository, because that exclusion list is already a copy of the release procedure's and a copy of a copy in nineteen places is how one skip list ends up with five spellings. Run it from the Actions tab when somebody needs a fix before it is on wordpress.org. The GitHub source zip cannot serve that: build/ is generated and absent from git, so a plugin with blocks arrives with none registered, and the zip unpacks under a name WordPress installs beside the copy already there rather than over it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01757bRbnEDK95mmzYaqupuP
Recent Commits to wp-pluginsused:master (49)
-
The actions run on Node 24, and Package moves to one shared job
-
Read the option rows with the plugin switched off
Read the option rows with the plugin switched off maybe_upgrade() moved from admin_init to init, so every `wp eval` the e2e helpers make now boots the plugin. That broke the upgrade fixtures twice over: the precondition check that reads the version row was the thing that stamped it, and the eval that writes the legacy row stamped the version first -- so the browser request that was meant to fold the row in returned early and left it behind. The row plumbing now goes through rowEval(), which passes --skip-plugins after the bare `--` wp-env wants. The mu-plugin fixture options and the helpers that need the plugin's own classes keep the plain wpEval. Also opens a test docblock with a capital, which phpcs asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HotBdNed6CpBUiz1Bpjvei
-
Carry the canonical comment sentences
Carry the canonical comment sentences Component init() docblocks read "Hook registration.", get_instance() and the capability filters are summarised the way the majority phrases them, and the network activation loop carries the two canonical sentences about the lifted site cap and the restore inside the loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Run the upgrade from init at priority 5, on every request
Run the upgrade from init at priority 5, on every request The routine hung off admin_init after register(), which is the ordering that once armed the default_option trap this plugin's migration was bitten by. init fires on every request and is where the majority of the collection runs maybe_upgrade() now; the bootstrap registers it so the admin-only Settings component no longer has to, and the migration runs before register_setting() installs its default. The suite helper now clears the plugin's rows in set_up, because the hook fires once while the suite boots and the stamped version row would gate migrate() out of every test. The registered-default regression test keeps the filter live by hand so a later hook move cannot rearm the trap silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Rename the canon methods to the collection's spellings
Rename the canon methods to the collection's spellings markers() and register() are the names the majority of the nineteen settled on; this plugin carried get_versions() and register_settings() for the same two jobs. Callers and tests follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
List the settings constants in the order their siblings read
List the settings constants in the order their siblings read GROUP now precedes PAGE in WP_PluginsUsed_Settings, the order every other settings class here lists them -- group, then page slug, then capability. The docblocks travel with their constants; nothing else moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Flatten the uninstall guard to the one-liner the rest uses
Flatten the uninstall guard to the one-liner the rest uses Every ABSPATH guard here is already the one-line defined() || exit form; uninstall.php still carried the if-block around exit(). The test pinning the guard now asserts the one-liner the way the other suites do, instead of a regex for the old block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Install the Claude workflows the collection now runs everywhere
Install the Claude workflows the collection now runs everywhere The same two files wp-postviews took in its PR #62: @claude answers on issues and PR comments, and every pull request gets a /code-review pass posted as inline comments. Verbatim copies; the CLAUDE_CODE_OAUTH_TOKEN secret has to exist in this repository for either to run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Give the upgrade tests the file the siblings give them
Give the upgrade tests the file the siblings give them The fold-in of pluginsused_options -- including the admin-path fold that is the plugin's own §-worthy bug -- and the marker coverage sat inside test-options.php while the sibling suites keep them in test-upgrade.php. Pure move, eight tests and no bodies changed; the options file keeps the storage and precedence coverage it is named for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Drive the uninstall fan-out from the suite, every call
Drive the uninstall fan-out from the suite, every call run_uninstall() required uninstall.php on first call -- whose file body runs the real network loop -- and went straight to the per-site function on every later call in the same process, which silently uninstalled only the current site. Two uninstall tests in one multisite run therefore exercised two different behaviours, and which test got which depended on execution order. The helper now carries the fan-out itself, the same loop the file runs with the same arguments, so every call does the same work. This is the form four sibling plugins already used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Bring the lockfile up to date with the shared ranges
Bring the lockfile up to date with the shared ranges One declared range had come to resolve differently across the collection -- @wordpress/eslint-plugin ^25.7.0 was locked at 25.7.0 in six plugins and 25.8.0 in thirteen, so npm ci ran a different rule set per repository and a clean lint in one predicted nothing about another. All nineteen lockfiles now resolve the same registry state (25.9.0); eslint and the vitest suites pass on the result. package.json is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Spell the test loader the way the other suites do
Spell the test loader the way the other suites do Eight spellings of one function had accumulated across the collection's bootstraps; the shared form is _wp_pluginsused_manually_load_plugin, underscore prefix included, which also puts the plugin's own prefix on a global the old name lacked. The name appears nowhere but this file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Stage 2.0.1 for the network-activation fix
Stage 2.0.1 for the network-activation fix Header, constant, Stable tag, changelog section and the metadata suite's expected_version() all move together. Deliberately unreleased so further fixes can accumulate; the release pre-flight ships it when asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Upgrade every site on a network activation, not just the current one
Upgrade every site on a network activation, not just the current one WP_PluginsUsed::activate() took no $network_wide and ran the upgrade against whichever site happened to be current. The settings and the version markers are per-site rows, so every other site on a network kept its pre-2.0.0 row unread -- including its hidden-plugins list, which is the setting whose absence actually publishes something. Nothing was lost -- migrate() only deletes the legacy row once it has folded it in, and the admin_init hook runs the same routine -- so a site healed the moment somebody opened its dashboard. A network whose subsites are front-end only never does, which is why this went unnoticed. tests/test-multisite.php covers the loop, the per-site activation that must not touch its neighbours, the uncapped get_sites() call, and the unwound blog stack. Removing the branch again fails two of the four. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVrFykSreGPwV1hTiRdQBY
-
Record 7.1 as the WordPress version this is tested against
Record 7.1 as the WordPress version this is tested against
-
Stop demanding an Upgrade Notice from a release that breaks nothing
Stop demanding an Upgrade Notice from a release that breaks nothing The shared metadata test asked every version for a section under Upgrade Notice. That section answers one question -- what will break for me, and what do I do about it -- so a release with nothing breaking could only answer it with a paragraph saying nothing breaks, which is padding in the one place a reader is scanning for a warning. It is now asked for only where the version's changelog carries a BREAKING entry. Every breaking change still has to be there, and still has to name each subject the plugin lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4HmKAe6zWSdBccApZ9Be9
-
Stop telling the reader which floors they upgraded from
Stop telling the reader which floors they upgraded from Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhW5aQPcDbSr6wbY4U6K62
-
Date the legacy row note to the pre-release builds that wrote it
Date the legacy row note to the pre-release builds that wrote it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhW5aQPcDbSr6wbY4U6K62
-
Let the translation decide the summary word order
Let the translation decide the summary word order The three counts were separately pluralised, which was always right, and then joined by a bare translated "and" with the spacing and the full stop concatenated around it. So the order the counts appear in was a fact about English held in PHP: a translator handed only "and" cannot put the inactive count first, and several languages want to. One joining string with numbered placeholders. The <strong> stays outside the msgid for the reason the comment above it already gives -- a translator has no reason to be handed markup. Rendered output is byte-identical, so the new test reorders the translation and asserts the sentence follows; that is the only thing that can tell the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Guard against padding inside a translatable string
Guard against padding inside a translatable string A translator sees a msgid in a list, where a leading or trailing space is invisible and a run of them is indistinguishable from one. They cannot reproduce what they cannot see, so padding survives only in English -- and where it was separating a label from a value concatenated after it, the translated string comes out run together. Three plugins in the family had it. This one does not, and the shared metadata fixture is where it is kept that way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Notice a plugin that arrived without WordPress being told
Notice a plugin that arrived without WordPress being told Caching the headers behind hooks and an expiry got the scan off the front end, and broke the plugin's one job on the way: a plugin uploaded over FTP, unzipped by hand or dropped in by a provisioning script fires none of those hooks, so the listing went on not listing it until the day ran out. The owner refreshes, sees the old list, and concludes the thing is broken. The e2e suite writes its hostile fixture straight to disk and caught exactly this. The entry names of the plugins directory are read on every request now and the stored headers are only reused while they still match. That is one directory read against get_plugins()' directory read plus a file opened and parsed for every plugin in it -- listing a directory was never the expensive part, and it is the whole of the saving. The expiry drops to an hour and covers what the fingerprint cannot see: a header edited inside a file whose name did not change, when WordPress was not the one editing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Stop scanning the plugins directory on every front-end request
Stop scanning the plugins directory on every front-end request get_plugins() opens the plugins directory, opens every plugin file in it and reads the first 8KB of each. Core caches the result -- in the `plugins` group, which wp_start_object_cache() registers as non-persistent, so the scan runs again on every request no matter what object cache the site has installed. That is an admin-screen cost, and these shortcodes run on the front end: any visitor arriving past the page cache pays it, as often as they care to ask, on a page whose whole content is a list that changes maybe twice a year. The headers are kept in a site transient for a day. A site transient rather than a plain one because the plugins directory is one directory for the whole network -- a per-site transient would leave every other site reading a copy the deletion never reached. Installing, updating and deleting a plugin discard it because they change what is on disk; activating and deactivating do not, but they are the moment a plugin uploaded over FTP is first noticed, and a plugin the cache has never seen is missing from the listing rather than merely on the wrong side of it. The expiry covers an upload nobody follows with any of the five. The hidden-plugins list and the version switch are applied to the headers on every request rather than baked into them. Filtering is free next to the scan, and caching before that step means a settings save needs no invalidation at all -- which is one fewer way for a hidden plugin to stay published. reset_plugin_state() in the test case now flushes both, so a suite that writes fixture plugins to disk between tests is not reading yesterday's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Stop keeping a logged-in session where it can be picked up
Stop keeping a logged-in session where it can be picked up artifacts/storage-states/admin.json is a real WordPress auth cookie pair, written by global-setup.js so the browser tests do not each log in through the form. It is scoped to the ephemeral wp-env container and worthless once that is gone, so nothing real leaks -- but it was sitting on disk in every plugin between runs, and two things sweep that directory up. The release rsyncs the working tree rather than a clean export, so the only thing keeping it off wordpress.org is one --exclude in a procedure that lives outside these repositories. This repository's own notes record that exact file class having shipped once already. And CI uploaded the whole of artifacts/ on failure, cookie included, for seven days. Narrowed to artifacts/test-results/, which is what the traces and screenshots are actually in. bin/test-e2e.sh no longer exec's Playwright, so it can remove the session whichever way the suite ends; global-setup.js writes it again on the next run. A session cookie is not a diagnostic, and keeping one is a habit rather than a decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Gate the block-renderer route, and match hidden names the way they ar…
Gate the block-renderer route, and match hidden names the way they are stored Registering a dynamic block is what creates /wp/v2/block-renderer/<name>, and core gates that route on edit_posts. So merely having this plugin active published the site's whole inventory -- inactive plugins and exact versions included -- to every Contributor, on a site that had never placed a listing anywhere. Core keeps the same inventory behind a higher capability of its own, and an inactive plugin is by definition one nobody has been updating. Gated on the route rather than inside the render callbacks, and that is load-bearing: do_blocks() also runs during a REST render of post content, so a check on REST_REQUEST would blank the block for every reader of a headless site. Dropping ServerSideRender from the editor would not close it either -- the route exists because the block is dynamic. manage_options rather than activate_plugins, because under multisite map_meta_cap() resolves the latter to manage_network_plugins for anyone who is not a super admin, which would have locked every site administrator on every network out of previewing a block on their own site. The multisite suite caught that. manage_options already decides which plugins are hidden, so the person configuring the list is the person who can preview it. Separately: the hidden-plugins list is stored through sanitize_text_field() and was compared against the raw Name header, which get_plugins() applies nothing but trim() to. Any plugin whose name holds a double space, a tab or a percent-octet could be ticked, saved with a success notice, and go on being listed -- with the checkbox coming back unticked, so it read as a save that had not worked. Both sides go through one normaliser now. Every existing fixture survives sanitisation byte for byte, including the "evil" one whose double quote passes straight through, which is why the round-trip looked covered; zzz-gappy is the shape that exercises it. And the per-request cache is dropped on switch_blog, so one request rendering for two sites no longer applies the first site's hidden list to the second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
The metadata fixture keeps its own copy of the canonical sections
The metadata fixture keeps its own copy of the canonical sections Adding Installation to the README meant adding it in two places, and only one of them was done: bin/verify.py went green while every plugin's own PHPUnit suite went red on an array diff whose whole content was "+ 1 => 'Installation'". The list lives in the shared fixture as well as in the checker, and nothing links them -- so the checker passing says nothing about the suite passing, which is exactly the sort of second source of truth this collection keeps finding.
-
An Installation section, because wordpress.org gives it a tab
An Installation section, because wordpress.org gives it a tab Every plugin gets one, including the ones whose honest answer is "activate it and you are done" -- a tab present on some of these and missing on others reads as an omission rather than as a decision. It sits between Description and Usage, which is the order a reader works in. Not boilerplate: "upload to wp-content/plugins/" is advice from before WordPress had a plugin installer and is not here. Step one is install and activate; everything after it is specific to this plugin, and where there is genuinely nothing to do it says so and says what happens instead. Setup steps that were sitting in Usage -- or, worse, in Description -- moved here, which is where somebody looking for them will go.
-
tests: a block's dependencies are core's, and are read off disk
tests: a block's dependencies are core's, and are read off disk The shared metadata fixture demanded an empty dependency array from every registered handle whose name starts with the slug. A block's editor script handle is minted by core from block.json and its dependencies are written by the build, so wp-blocks and wp-block-editor read as a §6 violation when they are what a block is. Block scripts are now held to their own rule: every dependency must be a handle WordPress itself registers, and none may be jQuery, because core ships jquery and "core provides it" alone would reopen the one door §6 exists to close. They are read from the build/*/*.asset.php manifest rather than from wp_scripts(). A block's handles are registered once, at the init the bootstrap fires, into a process-wide global that several plugins rebuild in set_up() - so whether the old loop saw a block at all depended on run order, and in three plugins it saw nothing and passed. fire_init() is for the tests that fire init a second time to watch a front end request: init has already fired once, so the second one re-registers the blocks, which is a _doing_it_wrong() notice. A real second request starts with an empty block registry, so this empties it of the plugin's own blocks first. The guard belongs here and not in the plugin - init fires once per request, and a second registration in production would be a real bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZG7R8RPFwPvWnSpUdKpoD
-
Three blocks, one per shortcode, sharing the shortcodes' renderer
Three blocks, one per shortcode, sharing the shortcodes' renderer Each block's render callback calls WP_PluginsUsed_Template::render() with the same string its shortcode's callback passes. Neither entry point calls the other: no do_shortcode() in a block, no block lookup in a shortcode, so either can be unregistered and the other carries on. All three shortcodes stay registered, documented and undeprecated, and the major version does not move. Three blocks rather than one with a listing-type attribute, even though the three shortcode callbacks differ only in that string. None of the shortcodes takes an attribute, so a type attribute would exist on one entry point and not the other; and a block name is fixed in post_content for the life of the post where an attribute is a value a later default can flip. Which listing a page shows belongs in the name. The hidden-plugins setting is the one thing here that is a disclosure rather than a nicety: somebody ticks a plugin because they do not want visitors knowing it is installed. Both the setting and the filter reach the block, because the block does no collecting of its own -- tests/test-blocks.php pins that through the render callback and through do_blocks(). This adds the first JavaScript build to the plugin. build/ is generated, gitignored and shipped; src/ is committed and not shipped. bin/build compiles and then walks build/ writing the silence-is-golden guards webpack knows nothing about, and bin/test.sh, bin/test-e2e.sh and the PHPUnit CI job all build first, so a checkout that has never been built cannot silently test the previous build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZG7R8RPFwPvWnSpUdKpoD
-
There is no link setting; the screen has two fields and neither is one
There is no link setting; the screen has two fields and neither is one The caption said the screen chooses "what each entry shows and where it links". add_settings_field() is called twice -- show_version and hidden_plugins -- so the screen offers the version toggle and the hide list and nothing about linking. Seventh caption corrected, and the third to promise a control that does not exist.
-
readme: describe the 2 screenshots that are actually shipping
readme: describe the 2 screenshots that are actually shipping Every image in assets/ was retaken against the rebuilt admin -- Settings API everywhere, WP_List_Table on the tabular screens, renamed headings -- so the old list described screens that no longer exist. wordpress.org captions positionally, by which line of this list matches which screenshot-N.png, so the count and the order both have to be right.
-
AGENTS.md: point at CLAUDE.md and stop pointing outside this repository
AGENTS.md: point at CLAUDE.md and stop pointing outside this repository Same defect CLAUDE.md carried and the same fix. This file linked to ../_standards/STANDARDS.md and described a collection of nineteen plugins, neither of which exists for anyone who has this repository and nothing else -- which is everyone who clones it, and, until plugin_deploy.sh was corrected, everyone who downloaded the zip. It is a signpost, so it now signposts the one file that is here.
-
CLAUDE.md: make it stand on its own in this repository
CLAUDE.md: make it stand on its own in this repository This file ships. The deploy rsync copies $SRC_DIR/*, which skips dotfiles -- so .claude/ and .github/ never leave -- but CLAUDE.md is not a dotfile and is not excluded, so it goes to wordpress.org with every release and lands in the zip every user downloads. Anyone reading it has this repository and nothing else. It did not read that way. It pointed at a standards document in a parent folder that is not part of this repository, cited section numbers from it, named sibling plugins as though the reader had them, and dated its own claims -- including claims about which tests were last green, which are wrong the moment they change and which CI answers properly anyway. The test applied: could someone who cloned only this repository act on every line? Where the answer was no, the substance is restated here and the citation dropped. Nothing is lost by removing the pointers, because anyone working across the whole set is told to read the shared documents first regardless. What stays: what the plugin is, its data, its traps and why, its own commit hashes -- which do resolve here -- its hook and API names, and how to run its tests. Test *status* is replaced by how to run them.
-
CLAUDE.md: say what the e2e suite's state actually is
CLAUDE.md: say what the e2e suite's state actually is The 'twelve suites _standards/RESUME.md lists as never run to green' pointed at a list that file no longer carries -- item 1 closed on 2026-08-05 and the list went with it. Eight plugins carried the reference, so eight had a cross-reference to nothing. Replaced with the state of this plugin's own suite, counted rather than copied, and explicit about which specs were actually run on 2026-08-05 and which were not. A suite nobody has run in one go still says so.
-
Tests: the migration assertion passes now, and the comment says so
Tests: the migration assertion passes now, and the comment says so The comment above it still read "This assertion currently fails, and it is right to". The fix landed -- migrate() and write() pass the explicit default, and the branch that adds the row is taken on the admin path -- so the comment was telling the next reader to wave through a real regression. Rewritten to say what the assertion pins rather than what it once caught: this is the admin_init path, opening the settings screen is what makes it that path, and a migration test that does not register the setting first is testing WP-CLI. The explanation of the original defect is kept, because the shape is worth recognising, but it is now in the past tense and ends in a warning not to weaken the test to the reactivation path. Verified rather than assumed: all 6 tests in upgrade.spec.js pass.
-
Tests: create administrators through one helper (§7.2.2)
Tests: create administrators through one helper (§7.2.2) 1 call site reached the user factory for an administrator directly. They now go through create_admin() on WP_PluginsUsed_TestCase, so what this plugin's capability means on a network is answered in one place rather than at each call site. No grant_super_admin() in the body. The screen takes manage_options, which core's map_meta_cap() does not remap under multisite, so a site administrator holds it on a network exactly as on a single site. Only wp-sweep, wp-dbmanager and wp-print need the grant, and granting anyway would make the fixture stop representing the operator this plugin actually has. Subscriber and editor fixtures are untouched: those assert the unprivileged path, and §7.2.2 says explicitly they must not be routed through the helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBmQQ91zKHQK16WFW4BdM4
-
Add the SessionStart hook that installs the toolchain CI gates on
Add the SessionStart hook that installs the toolchain CI gates on A fresh session arrives with php and node and neither of the two things that decide whether a push is green. php -l and verify.py both pass on code phpcs rejects, and treating them as the gate put four red commits on master in one afternoon. The hook installs Node 24 (what ci.yml pins; the npm in Node 22 calls every lockfile here out of sync, which is a false alarm), phpcs with WPCS, this plugin's npm dependencies, and starts the Docker daemon, which is installed but not running. Idempotent and remote-only. Byte-identical in all nineteen plugins and in the tooling repository -- it names no plugin and branches on what it finds -- and bin/verify.py now holds the copies to _standards/templates. PHPUnit and Playwright still belong to CI, and not for want of Docker: wp-env downloads WordPress from *.wordpress.org, which the session egress policy blocks. The hook says so on every run.
-
Tests: scope the class scan to the plugin's own directories
Tests: scope the class scan to the plugin's own directories The previous commit matched every class whose file sat under the plugin root and subtracted tests/. Under CI the root also holds vendor/, where Composer declares ComposerAutoloaderInit<hash>, so the PHPUnit jobs failed on a class that is not this plugin's to name. It passed locally because vendor/ only exists after composer install, which CI does and a working tree here does not -- so the check I ran before pushing looked at a smaller world than the one that runs it. Matches includes/ and the root entry points instead. An allow list cannot acquire a new member because somebody installed a dependency.
-
Tests: take the placeholders out of the metadata fixture comments
Tests: take the placeholders out of the metadata fixture comments The file is deliberately byte-identical in all nineteen -- it reaches the plugin's own fixture base class through the Plugin_TestCase alias so that it never has to name a plugin. Four of its docblocks still carried {{CLASS}} and {{UNDER}} from the template, so every copy shipped documentation reading "{{UNDER}}_version marker row". Phrased generically instead of substituted, because substituting would have made the nineteen copies differ, which is the property the file is built around. -
Tests: make the class naming test capable of failing
Tests: make the class naming test capable of failing Half of it compared two literals: the class names came from an array written into the test, so asking whether 'WP_PluginsUsed_Options' starts with 'WP_PluginsUsed' could not fail. The file half was real but only ever asked about the four names already typed, so a fifth class was invisible to both halves. Now it asks PHP which classes came from files under the plugin directory. The file check also got stronger on the way past: it compares against the file the class was really declared in rather than asking whether a file of the expected name exists somewhere.
-
Message the remaining assertions, taking this suite to 169 of 169
Message the remaining assertions, taking this suite to 169 of 169 The earlier pass did the 51 that failed illegibly. These are the other 118, which already print expected against actual -- so the message names the rule the comparison is testing rather than the values it compares. The singular and plural tests are the ones that pay for this. Six assertions across three fixtures, all comparing a string that says "plugin" or "plugins", and the diff for a failure is a paragraph of markup with one letter wrong in it. The messages say which count and which form, so the reader knows whether the total, the active or the inactive line is the one that stopped agreeing with itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBmQQ91zKHQK16WFW4BdM4
-
Give a failure message to the assertions that fail without one
Give a failure message to the assertions that fail without one The comparing assertions here already print expected against actual, so a message on those would restate the line above. These are the rest: predicates whose whole failure output is "Failed asserting that false is true", and the ones inside a loop, where PHPUnit cannot say which case of the set broke. The loop ones take the loop variable, so a failure names the column, the symbol or the plugin rather than sending the reader back to the fixture. Several are the fixture assertions that guard a later one -- "this really was rendered", "there really were shares". Those carry the reason they exist, because a reader who does not know they are guards reads them as redundant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBmQQ91zKHQK16WFW4BdM4
-
Write the settings row through a helper that can create it
Write the settings row through a helper that can create it update_option() declines to write a value equal to the one get_option() would return, and register_setting() is passed a default, which installs a default_option filter answering with the shipped defaults for a row that does not exist. So on an admin request -- the path every real update takes, because activation hooks do not fire on an update -- a migration whose result happens to equal the defaults writes nothing at all, while the legacy rows it read are deleted anyway. Latent here rather than live, and I checked before changing anything: get() merges over the defaults in every one of these plugins, so a missing row and a defaults row read identically and nothing is lost today. It stops being latent the moment one of them gains a setting whose absence means something other than its default, and then the failure is silent, browser-only, and the legacy rows are already gone. This is the shape that cost wp-print and wp-pluginsused a release blocker each earlier today, so §7.6.1 exists and names wp-print as the reference. Eight plugins can reach it; two already had the helper. This is the rest. Ordinary setters -- update(), save() -- are untouched. There the behaviour is correct and expected, and only an upgrade path that then deletes the rows it read carries the risk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1KNA2t61SoWKZamqPF5sx
-
Say Plugins Used Settings, and take WP- out of the page title
Say Plugins Used Settings, and take WP- out of the page title The heading and the page title are two different rules, and this plugin was on the wrong side of both. STANDARDS §4.1 says the sidebar carries the plugin name with its WP- prefix, because that is the string a site owner matches against the list of everything they installed, and that the page title and the <h1> never do, because by then they know where they are. Task #17 adds that a settings screen's heading ends in the word "Settings". The <h1> read "Plugins Used", which is a fair description of the screen and is not what the other fourteen settings screens do. The page title passed to add_options_page() read "WP-PluginsUsed" -- the browser tab, and what a screen reader announces on arrival, which is exactly the place §4.1 calls the prefix noise. Only three plugins still had it there. §4.1 had been demonstrating the opposite of the first rule, because two of its six worked examples were settings screens written before #17 existed, and "Plugins Used" was one of them. The standard is corrected in the same pass; an example is a rule, and it is the part people copy. The Settings entry in the sidebar is untouched and still reads WP-PluginsUsed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1KNA2t61SoWKZamqPF5sx
-
Point the licence block at the address the FSF actually uses
Point the licence block at the address the FSF actually uses Sixteen plugins told a reader to write to the Free Software Foundation at 59 Temple Place, Suite 330 -- an address the FSF left in 2005, and which survives across most of the WordPress plugin directory purely because the block gets copied rather than read. Three said 51 Franklin St: the right building, with the street name abbreviated. Neither spelling is what the FSF publishes with GPL-2.0 today. Nineteen files have to agree either way, so converging costs the same in both directions and only one direction is also true. This is a postal address the paragraph directly instructs a reader to write to. Nothing else in the block differed anywhere in the collection -- it was this line and only this line, which is what made it so easy to miss. §3.1 now reproduces the block in full instead of eliding its tail with a "…", and bin/verify.py compares it byte for byte. The elision is how this drifted: the header check that was meant to cover the licence read only the "License:" field, three lines above the part that was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1KNA2t61SoWKZamqPF5sx
-
Read the raw row, not one register_setting() can synthesise
Read the raw row, not one register_setting() can synthesise On the admin_init path -- the path every real update takes, because activation hooks do not fire when a plugin is updated -- the migration deleted the owner's hidden-plugins list and wrote nothing in its place. register_setting() is passed a 'default', which installs a default_option_wp_pluginsused_options filter, and register_settings() is hooked to admin_init ahead of maybe_upgrade(). So by the time migrate() runs, a bare get_option() answers with the defaults array and never with false. The "there is no current row yet" branch was therefore never taken, while the delete_option() a few lines below ran regardless. Passing an explicit default defeats the registered one: filter_default_option() returns early when a default was passed. Reactivating repaired it, which is exactly why it survived. WP-CLI never runs register_setting(), so the branch is taken there and every existing test -- all of which reach the migration through activation -- passed on the bug. The new test registers the setting first, which is the only difference between the two paths. Verified by putting the bare get_option() back: the new test fails with "The migration wrote no settings row at all." Severity is bounded by history rather than by the code: 1.50 stored nothing at all, so LEGACY_OPTION only exists on an install that ran an unreleased 2.0.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1KNA2t61SoWKZamqPF5sx
-
Point AGENTS.md at CLAUDE.md rather than copying it
Point AGENTS.md at CLAUDE.md rather than copying it A tool looking for AGENTS.md now finds its way to the briefing instead of finding nothing. It is a pointer and stays one: guidance copied into two files is two files that disagree, and the copy is always the one nobody updates.
-
Write down what the source cannot say about this plugin
Write down what the source cannot say about this plugin The briefing a competent stranger needs before touching it: what it does, what it stores, what couples it to its siblings, and the things that look wrong and are deliberate. Everything derivable from reading the code is left out, and so is anything STANDARDS.md already says -- restating a collection-wide rule per plugin only creates nineteen places for it to drift.
-
Extend one shared metadata base instead of nineteen copies
Extend one shared metadata base instead of nineteen copies Nineteen files of 298 to 666 lines, 14 to 33 tests each, eleven tests in common and four that nine plugins had never received. Each read reasonably on its own, which is why nobody noticed that wp-polls -- about to ship -- had never been checked for a licence file. They now extend Plugin_Metadata_TestCase, wired by two lines in bootstrap.php: a class_alias, because the copies must be byte-identical while each plugin's base class has a different name. Every copy hashes the same, and that check belongs in the workflow rather than in somebody's memory: three defects were found in the base AFTER it had been copied to eighteen plugins, including an assertion that quietly depended on the slug sorting after uninstall.php -- true of every wp-* plugin and false of freemyinternet. Reading the nineteen as one turned up three plugins mishandling the shared WP-Stats rows, two of them on the uninstall path. None was visible from inside its own plugin.
-
Add a Playwright suite, UNVERIFIED
Add a Playwright suite, UNVERIFIED Written by an agent that hit a session limit before running it. Nothing here has been seen green: treat every assertion as a claim, not a fact, and run bin/test-e2e.sh before believing any of it. Committed rather than left loose only so the work survives. It must not be pushed until it has passed, because the last time unverified specs went out they took CI red across five repositories.
-
Say what changed and stop explaining WordPress to the reader
Say what changed and stop explaining WordPress to the reader The Upgrade Notice sections had grown a preamble apiece -- "a major release", "five things are worth knowing before you update" -- and then opened with a paragraph explaining that an old site will not be offered the update, which is WordPress's behaviour rather than anything this plugin does, in seven different phrasings across the collection. What a reader wants from this section is the list of things that will break and what to do about each; everything else was standing between them and it. Every section now opens with one line naming the requirement and goes straight into the changes. The reassurance, the closing "there is nothing to do", and the instructions on how to ask a host about PHP are gone. The technical content is unchanged: every renamed hook, moved option row, replaced template and changed URL is still named, because that is the part somebody actually needs. wp-polls gains a line it should always have had: the stored XSS is the reason to take that release, and it was recorded only in the changelog. 9,600 words to 5,900.
-
Keep the changelog to the current major
Keep the changelog to the current major Nineteen READMEs carried up to thirty entries each, back to 2006 in places: 308 entries across the collection describing point releases of versions nobody is running. What a reader needs from a changelog is what changed in the version they are about to install, and what will break for them -- and the second of those has lived in Upgrade Notice since the standard was written. Nothing else in the file moved; the diffs are deletions only. test-metadata.php also learns to skip artifacts/, where Playwright writes traces and screenshots. Without it the first plugin to gain an e2e suite starts failing the every-directory-has-an-index.php test, and only for whoever has run that suite locally -- so CI stays green and the failure looks like it came from whatever was touched that day.
-
Drop the eslint job, which could never have passed here
Drop the eslint job, which could never have passed here This plugin ships no JavaScript: no js/ directory, no scripts, no package.json. The workflow carried a "JS coding standards and tests" job anyway, and it did not fail at the linter -- it never reached one. It died at actions/setup-node, looking for a lock file: Dependencies lock file is not found in /home/runner/work/... Supported file patterns: package-lock.json, npm-shrinkwrap.json, yarn.lock A job that cannot pass is worse than no job. It puts a red mark on every push for a reason nobody can act on, and teaches everyone to stop reading the marks. The shared standard said "a plugin with no js/ directory deletes the whole eslint job". This plugin has no js/ directory, so the job should have gone when the workflow was written; the check that compares a workflow to the template was tolerating its absence rather than requiring it, so nothing noticed either way. -
Name the screen, not the plugin, in the page heading
Name the screen, not the plugin, in the page heading "WP-PluginsUsed" as the h1 duplicated the sidebar entry. The heading says what the screen is now -- Plugins Used -- matching Ban Options, Manage Ratings, Stats and the rest. STANDARDS.md 4.1: the prefix belongs in the menu title, never in the heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018W7cKqYNL1gUFTi38VRHaX
-
Correct the record on wp_get_sites(), here and in the CI comment
Correct the record on wp_get_sites(), here and in the CI comment This plugin repeated a claim that runs through the whole collection: that wp_get_sites() was removed in WordPress 5.1 and that calling it fatals. Neither is true. It was deprecated in 4.6 and still ships in ms-deprecated.php, which is loaded for multisite only -- so on a single site it merely looks absent. What it actually does is worse than a crash: it returns only the first 100 sites, so network activation and uninstall quietly did their work on a hundred sites and reported success. Comments, docblocks, assertion messages and the test method names that encoded the false version are all corrected to say that. No behaviour changes -- the code already used get_sites() with 'number' => 0. The CI matrix comment carried the same claim and moves with the shared template. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018W7cKqYNL1gUFTi38VRHaX
-
Stop claiming what the reader is upgrading from
Stop claiming what the reader is upgrading from The Upgrade Notice said the floors moved 'up from 6.0 and 7.4', or advised anyone 'still on PHP 7.4' to move. Both are wrong about the version actually on wordpress.org. Queried against plugins.svn.wordpress.org, the released readmes declare WordPress 2.8 to 5.5, and fifteen of the nineteen declare no Requires PHP at all. Three plugins had ended up asserting three different predecessors, none of them right - and telling a reader who may be on 5.6 that they are on 7.4 is worse than saying nothing, because it reads as confirmation they are fine. What the notice can state truthfully is the requirement, the consequence and how to check: an older site is not offered the update, and Site Health reports the PHP version. That holds wherever the reader is coming from.
-
Emit the markup kses leaves, and assert loss rather than bytes
Emit the markup kses leaves, and assert loss rather than bytes The plugin has two ways out: the shortcodes return their markup, while display_pluginsused() echoes it through wp_kses(). kses normalises as well as filters, so the two paths were emitting different bytes for the same listing - viewBox lowercased to viewbox, a space added before a self-closing slash, ' rewritten to ' inside a title. A theme calling the template tag got markup the shortcode never produced. The icon now spells its attributes the way kses leaves them, so the two paths agree. Lowercase viewbox is safe and is what the echoing path has always shipped: an HTML parser applies its SVG case-fixup table, so it reaches the DOM as viewBox and the icon still scales. The entity rewriting cannot be pre-empted that way, so the two tests now assert what they mean rather than byte-identity. The allow-list test compares inventories of tags and attribute names, since an incomplete allow list drops a tag or an attribute and that is the failure worth catching. The template-tag test compares the echoed output against wp_kses( render(), allowed_html() ), which is what it should equal. Also made the settings_fields() assertion quote-agnostic: core quotes option_page with single quotes, which is not part of any contract. 158 tests, 442 assertions, green.
-
Match wp_enqueue_script( with its bracket, not as a substring
Match wp_enqueue_script( with its bracket, not as a substring The no-jQuery test forbade the bare string 'wp_enqueue_script', which is also a substring of the ACTION name wp_enqueue_scripts - the hook a plugin legitimately uses to enqueue a stylesheet. Registering no scripts is not the same as never touching the hook scripts are registered on. wp-stats failed on exactly this: it hooks wp_enqueue_scripts to add its one sheet. The others here pass today only because they happen not to, so this is a latent false failure waiting for the first stylesheet. Found by the first PHPUnit run of this work.
-
Re-lock composer against the PHP 8.2 floor
Re-lock composer against the PHP 8.2 floor composer.json moved to >=8.2 with the floor raise but the lock file still recorded >=7.4. Composer treats that as an error rather than a warning, so 'composer install' would have failed inside the tests container before PHPUnit ran a single test - and CI does exactly that on all six matrix rows. bin/verify.py now compares the two, so the pair cannot drift again.
-
Step 12: drop the last two phpcs suppressions and fix the code under …
Step 12: drop the last two phpcs suppressions and fix the code under them Both were in includes/, both had a reason written next to them, and the target for this collection is zero. display_pluginsused() echoed its markup behind an EscapeOutput ignore. It now escapes at the sink like everything else, through wp_kses() with an allow-list that says exactly which tags and attributes the listings use. A test asserts wp_kses() leaves all three listings byte-identical, so the day the markup grows a tag the list has not got, the suite says so rather than the template tag quietly printing less than the shortcodes do. The summary sentence carried an I18n.NoHtmlWrappedStrings ignore arguing that rewording the msgid would orphan its translations. That is true and it is still the right trade: a translator has no business being handed <strong>, and a msgid wrapped in a tag is a translation waiting to lose it. The tags moved into the concatenation, the rendered string is byte-for-byte what it was, and the three orphaned msgids are recorded as a NOTE in the changelog. phpcs and php -l are clean across the plugin, and bin/verify.py reports zero.
-
Step 11: put the suite on the house conventions and add the shared tests
Step 11: put the suite on the house conventions and add the shared tests Files and classes follow 7.1: helper-fixtures.php becomes helper-testcase.php because what it declares is the base test case, test-stats.php becomes test-summary.php so nothing confuses it with the WP-Stats integration this plugin does not have, and every class is WP_PluginsUsed_<Area>_Test extending WP_PluginsUsed_TestCase. test-bootstrap.php and test-uninstall.php extended WP_UnitTestCase directly and now go through the shared base like the rest. Discovery is by the test- prefix now, so a misnamed file would be silently skipped rather than fail: 109 test methods before this step, 150 after, none lost in the rename. tests/test-metadata.php carries the fourteen shared assertions from 7.2, plus three specific to what this plugin just did: that every fired hook carries the prefix, that no apply_filters_deprecated shim crept back in, and that the Upgrade Notice names all three renamed filters in both spellings. The last one is the guard that matters -- the rename is only survivable for a site owner if the notice actually lists it. run_uninstall() is shared rather than repeated, because uninstall.php declares a function and so can only be included once per process. Two files assert against it; the second include would have been a silent no-op and the test would have passed on rows nothing had deleted.