Recent Commits to wp-pluginsused:master (48)

  • 23/08/2026 2:13

    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
  • 22/08/2026 20:42

    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
  • 22/08/2026 20:42

    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
  • 22/08/2026 20:41

    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
  • 22/08/2026 0:36

    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
  • 22/08/2026 0:36

    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
  • 21/08/2026 23:36

    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
  • 21/08/2026 12:03

    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
  • 21/08/2026 11:22

    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
  • 21/08/2026 11:10

    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
  • 21/08/2026 11:08

    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
  • 21/08/2026 10:50

    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
  • 21/08/2026 10:38

    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
  • 19/08/2026 21:46

    Record 7.1 as the WordPress version this is tested against

    Record 7.1 as the WordPress version this is tested against
  • 10/08/2026 5:10

    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
  • 10/08/2026 2:37

    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
  • 10/08/2026 1:56

    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
  • 08/08/2026 23:09

    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
  • 08/08/2026 21:37

    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
  • 08/08/2026 18:24

    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
  • 08/08/2026 14:48

    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
  • 08/08/2026 13:52

    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
  • 08/08/2026 13:00

    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
  • 08/08/2026 7:05

    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.
  • 08/08/2026 6:46

    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.
  • 08/08/2026 2:59

    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
  • 08/08/2026 2:18

    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
  • 08/08/2026 0:04

    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.
  • 07/08/2026 12:35

    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.
  • 04/08/2026 22:03

    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.
  • 04/08/2026 21:42

    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.
  • 04/08/2026 21:22

    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.
  • 04/08/2026 10:13

    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.
  • 03/08/2026 7:37

    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
  • 03/08/2026 5:24

    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.
  • 03/08/2026 4:06

    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.
  • 03/08/2026 4:00

    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.
  • 03/08/2026 3:57

    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.
  • 03/08/2026 2:17

    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
  • 03/08/2026 1:13

    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
  • 02/08/2026 0:54

    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
  • 02/08/2026 0:03

    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
  • 01/08/2026 23:46

    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
  • 01/08/2026 23:17

    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
  • 01/08/2026 13:57

    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.
  • 01/08/2026 13:37

    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.
  • 01/08/2026 11:33

    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.
  • 01/08/2026 8:21

    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.
  • 01/08/2026 4:34

    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.
    Tags:
  • 01/08/2026 3:55

    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.

Log in