Skip to content

6.22.0 — the suppression-audit arc, plus four fixes and one deprecation - #1051

Merged
rpgmem merged 24 commits into
mainfrom
develop
Sep 4, 2026
Merged

6.22.0 — the suppression-audit arc, plus four fixes and one deprecation#1051
rpgmem merged 24 commits into
mainfrom
develop

Conversation

@rpgmem

@rpgmem rpgmem commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Release PR: consolidates the 23-commit develop batch into main under one version bump.

Draft on purpose — this is the production deploy. Flip to ready + auto-merge only when the batch has been validated against the testes site.

The batch

The suppression-audit arc (#1027, #1028, #1030, #1034, #1035) — five issues that started from "why is this line suppressed?" and ended with the gates actually running:

Fixes

Deprecated

Version

6.21.0 → 6.22.0 (minor: a deprecation notice plus user-visible fixes). Bumped in the three sync sites by #1050, which also renamed [Unreleased] to ## [6.22.0] (2026-09-04).

No assets/** JS or CSS changed since v6.21.0 — the cache-key rotation this release carries is for the pt_BR language update (025e1a02).

After the merge

  1. Tag the squash commit to publish the Release + zip (release.yml fires on v*).
  2. Hard-reset develop to main — not a rebase; the squash already contains every develop commit.
  3. Backfill the release commit's short SHA onto the ## [6.22.0] heading, in a PR that lands after the reset.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ


Generated by Claude Code

rpgmem and others added 23 commits September 1, 2026 23:40
The squash of #1019 landed on main as ae38302. Every shipped version heading
carries its release commit reference; this adds 6.21.0's.

Cut onto the post-release develop rather than before it: the sync is a hard
reset to main, so a backfill landing first would have been discarded — the
same order #1004 and 037001f followed after 6.20.1 and 6.20.0.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…1021)

Two errors in the release section, both found by following it during the
6.21.0 release.

The first: step 4 said to do the CHANGELOG short-SHA backfill "before tagging
so the tagged tree carries it". That is not achievable and has never happened.
The post-release sync is a hard reset of develop to main, so a backfill landing
before it is silently discarded — which is why #1004 and 037001f both cut
theirs onto the post-release develop instead. Checked against the artifact
rather than the prose: v6.20.1's tagged tree reads `## [6.20.1] (2026-08-30)`
with no suffix, and main only acquired that suffix one release later.

The backfill therefore becomes its own step 6, after the sync, stating where
it lands and why the order is forced. The consequence is named as structural
rather than as a defect: the newest shipped heading is expected to lack its
suffix until the next release carries it, so only a missing suffix on an
*older* heading means the backfill was actually skipped. Step 2 no longer
carries the instruction at all — it sat under "In that PR", which is the bump
commit, and that placement is what made the wrong order look right.

The second: step 5 said to rebase develop on main after a release, while the
Sync section it points at says post-release must be a reset, since the squash
already contains every develop commit. Step 5 now says reset and gives the
reason, so the two agree.

Docs only — no CHANGELOG entry, matching #1006 and #1017, which changed repo
conventions without touching plugin behaviour.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…them (#1023)

The update screen told every WordPress 7.1 site "Compatibility with WordPress
7.1: Not tested" for a release that had been verified against 7.1.

GitHubUpdater hard-coded the three compatibility fields it puts into the
update-transient object, as constants kept in sync with readme.txt and the
plugin header by nothing but a docblock asking politely. Both WP values had
drifted: WP_TESTED still read 7.0 after #984 raised readme.txt to 7.1, and
WP_REQUIRES read 6.2 against an actual 6.4. The stale copy is the one that
counts — list_plugin_updates() compares the transient's `tested` field against
the running version and never reads readme.txt, because this plugin updates
from GitHub rather than the WordPress.org directory. WP_REQUIRES was the
quieter half: understating the floor offers the update to 6.2 and 6.3 sites
that cannot run it.

Rather than correcting the constants and leaving the third copy in place, the
values are now read with get_file_data() from the files that own them, so the
class of bug is gone rather than reset. Two files, not one, and that is the
floor: both are parsed by tooling before PHP runs — WordPress reads the plugin
header to decide whether to load the plugin at all, WordPress.org reads
readme.txt — so neither can be derived from the other. "Tested up to" is not a
plugin-header field in the first place; it exists only in readme.txt.

Not memoised. A static cache would be process-global mutable state that every
test has to reset, bought for two 8 KB reads that happen on an update check and
not on ordinary requests.

Three guards, because the runtime read moves the failure mode rather than
removing it — a renamed or deleted header makes get_file_data() return an empty
string silently, which is a worse symptom than a stale one:

- GithubUpdaterTest asserts the built update object carries exactly what the
  two files declare. Its get_file_data() stub is aliased to a real parse of the
  real files, not a fixture, since a fixture could agree with a stale value.
- GitHubUpdaterCompatTest pins the headers the read depends on: present,
  non-empty, shaped like a version, and agreeing where readme.txt and the
  header overlap. It also fails if a hard-coded constant comes back, and if
  .distignore ever excludes readme.txt — which would break the read only on
  installed sites, never in a checkout.
- Both were checked against the mutations they claim to catch (renamed header,
  disagreeing files), not just against green.

Full suite: 7445 tests, 21483 assertions.

Closes #1022


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…link action (#1025)

Two defects, one reported and one found while diagnosing it.

The reported one: the activity log showed "Capabilities Granted" untranslated on
a Portuguese install. get_action_label() falls back to ucwords() for any action
it does not know, which renders untranslated English in every locale. The
fallback is silent, so a missing label looks deliberate — nothing fails and
nothing warns.

It was not one string. The map carried 15 labels; the log writes 64 action
keys. The other 49 all went through the fallback: every recruitment action,
every privacy action, every migration, plus scheduling, reregistration, bulk
operations, decrypt failures and operator-issued certificates. The same helper
feeds three surfaces — the log table, the per-action summary, and the CSV
export. All 49 now have labels.

The found one: SubmissionHandler passed the action name where the level
belongs.

    $action = $user_id ? 'user_linked' : 'user_unlinked';
    ActivityLog::log( 'submission', $action, ... );

against log( string $action, string $level = LEVEL_INFO, ... ). So linking and
unlinking a submission both recorded the generic `submission`, the real action
name landed in the level slot and was discarded by the level validation, and
category_for_action() fell through to its `submissions` default. Auditing could
not tell a link from an unlink, and no label would have fixed that. The
arguments are corrected; `submission` keeps a label marked legacy so rows
already written stay readable, and nothing writes it any more.

ActivityLogActionLabelsTest is the ratchet. It collects action keys from
ActivityLog::log() call sites and from category_for_action()'s map, and fails
when one has no label. Three things it was built around:

- It is narrow about the receiver. A first, looser pass matched `::log(` on any
  class and swept in keys belonging to other loggers, which is how `submission`
  first surfaced as an "action" — a false positive that happened to lead to a
  real bug.
- Its own sanity check caught a second trap: the page declares an unrelated
  `$labels` map in get_preflight_reason_label(), and slicing on the variable
  name alone silently mixed the two. The extraction anchors on the method.
- It asserts labels are wrapped in __(), not merely present, since a bare
  string would otherwise read as "missing" for the wrong reason.

Checked against the mutations it claims to catch — a deleted label and a label
stripped of its __() call both fail it.

The fallback stays: rows written by an older version can carry a key the code
no longer has, and those must still render something. The guard is what stops a
current action from relying on it.

Full suite: 7448 tests, 21494 assertions.

Closes #1024


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…1026)

CertTemplateSeeder::maybe_seed() wrote the seed-version flag unconditionally,
including when the run created nothing. The whole path fails silently:
Utils::read_file_contents() returns '' for a file it cannot read, seed() then
continues past that definition without a word, and the flag short-circuits
every later run — so a first seed that read nothing left the pool permanently
empty and never retried.

That is not cosmetic. An empty pool is exactly the condition under which
AdminAssetsManager::discover_layout_templates() falls through to the deprecated
legacy html/ glob, so the fallback's stated exit condition ("removed once the
pool seeds on every install") could not be met while this hole existed.

The flag is now written only once pool_has_defaults() confirms the pool holds a
shipped default; otherwise the run leaves it alone and retries on the next
admin request, with an off-by-default Debug::log_admin breadcrumb.

The guard is deliberately narrow — "not empty", not "everything seeded". A
partial seed still populates the picker and keeps the fallback dormant, and
gating on completeness would re-run restore()'s meta writes on every admin
request for as long as one file stayed unreadable.

Also registers the html/ fallback in the CLAUDE.md shim inventory with its
exit condition: evidence-gated, not a versioned deprecation cycle, and split
across two releases so a repaired install is observed before losing the net.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
) (#1029)

All 31 PHPStan suppressions in includes/ used the form

    @phpstan-ignore-next-line argument.type

which does NOT filter by that identifier — the -next-line variant suppresses
every error on the line and treats the trailing text as a comment. Each one
therefore read as narrow and behaved as a blanket. Measured with PHPStan
2.1.17 against the pinned wordpress-stubs: converting all 31 to the filtering
`@phpstan-ignore <id>` form changes nothing except surfacing one previously
hidden error.

That error was real. ReregistrationSubmissionReader::get_by_reregistration()
returned $wpdb->get_results() straight through, but get_results() returns null
on a failed query and the method declares `: array` — a TypeError waiting for
the first failing query. Fixed with the idiom the same file already uses two
methods below (is_array() guard + @var cast).

Necessity was established per suppression rather than assumed: neutralising
the 17 placeholder-docblock suppressions made all 17 literal-string errors
appear, so none was dead. Three of them were removable a better way — the
export WHERE-clause builder really does return a literal-string (every
fragment is a literal carrying placeholders; values travel separately), so
declaring that is honest, PHPStan verifies it, and the three suppressions
downstream stop being needed. The remaining 14 are irreducible and now say
what is reported and why the interpolated fragment is code-chosen.

%i does not help any of them, contrary to the issue's hypothesis: every
identifier already uses %i, and what breaks literal-string is clause-level
interpolation (WHERE/ORDER BY/LIMIT), which %i cannot express.

Adds tests/Unit/PhpstanSuppressionTest.php, which fails on a suppression that
uses the broad form, names no identifier, or carries no reason.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…1031)

Necessity was established per annotation rather than assumed. Each of the 925
phpcs:ignore annotations in includes/ was neutralised in place (rewritten to
name a sniff that does not exist, so the line stays a valid directive and the
comment syntax is unchanged) and PHPCS re-run; an annotation whose target line
then reported none of the sniffs it names is doing nothing.

Measured against the stricter of two configurations, so the result holds either
way: 520 load-bearing, 59 over-broad, 334 dead, 12 inconclusive. The 333 dead
ones that are real directives are removed here (the 334th is prose in a
docblock that merely mentions phpcs:ignore, not a directive).

Verified three ways rather than by the model that selected them:
  - PHPCS under the committed ruleset is clean, as before.
  - PHPCS with WordPress.DB.DirectDatabaseQuery and
    WordPress.Security.ValidatedSanitizedInput additionally enabled reports
    292 violations, exactly as it did before the removal — so nothing was
    unmasked under the stricter configuration either.
  - The diff is token-identical to HEAD ignoring whitespace and comments: no
    code changed.

Deleting a directive line between two assignments merged two alignment blocks
and produced 24 MultipleStatementAlignment warnings; phpcbf was run with only
that sniff enabled to re-align them, which is the whitespace in the diff.

The audit also explains why so many were inert, and that part is a finding
rather than a cleanup: the two sniffs above are not part of WordPress-Extra,
which is what phpcs.xml.dist references, so they have never run in this
repository. 520 annotations are written as if they do. Enabling them turns
those 520 into real suppressions and surfaces 292 sites nobody has triaged.
That decision is left open on #1028; this commit only removes what is inert
under both answers.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…sites (#1028) (#1032)

WordPress.Security.ValidatedSanitizedInput is not part of WordPress-Extra — it
lives only in the full WordPress standard — so it had never run in this
repository, while dozens of annotations across the codebase were written as if
it did. This enables it on the existing ruleset and leaves the gate clean.

All 50 sites it reports were read individually first, because this is the sniff
where a real bug could have been hiding. None was: every one is the same false
positive, where sanitisation happens in the next statement rather than the same
expression. The forms found, each now stated at the site rather than assumed:

  - a shared sanitiser one call away (build_query_args(), sanitize_value())
  - an (int) or floatval() cast, which sanitises
  - a strict '1' === comparison, where the raw value never survives
  - wp_kses() with the HtmlPolicy allowlist, for the two template-HTML saves
    that must not go through sanitize_text_field()
  - ColorValidator::normalize(), array_map( 'intval' ), RequestInput::is_truthy()
  - an HMAC-verified cookie payload
  - is_uploaded_file() on a tmp_name that PHP generates and the client never sends
  - RequestInput itself, which is the sanitiser the rest of the codebase calls

Eleven sites already carried a NonceVerification suppression; those were merged
into rather than duplicated, keeping the existing reason and appending the new
one. Inserting a comment between two assignments split an alignment block in
four files, so phpcbf was run there with only that sniff enabled.

The value of turning it on is not the 50 — they were all fine. It is that the
51st unsanitised read now fails CI instead of passing unnoticed.

Verified: the committed gate is clean across the whole project, not only
includes/; the diff is token-identical to HEAD ignoring whitespace and comments;
PHPStan unchanged at 192 (the same environment-artifact set).

DirectDatabaseQuery, the other sniff the audit found inert, is deliberately not
enabled here — its 129 sites need their own pass, and NoCaching in particular
cannot be justified in bulk.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
#1028) (#1033)

WordPress.DB.DirectDatabaseQuery is the second of the two sniffs the
suppression audit found had never run here — like ValidatedSanitizedInput it
lives only in the full WordPress standard, not in WordPress-Extra. This enables
it on the existing ruleset and leaves the gate clean across the whole project.

The 129 sites were classified before being annotated, because a single stamped
reason across all of them would repeat the habit #1028 exists to correct:

   25  transaction control (START TRANSACTION / COMMIT / ROLLBACK) — touches no
       table, so there is nothing to cache or invalidate
   47  writes to the plugin's own ffc_* tables, which WordPress has no API for
    3  schema changes in activators and migrations, which dbDelta cannot emit
    7  reads that ARE the cache-miss path — the hit is the cache_get() above
   17  reads in migrations, privacy erasers/exporters and activators, which
       must read live state, so caching would be wrong rather than useless
    7  uniqueness and create-probes, where a cached answer defeats the check
    3  export cursors, where each page is read exactly once
   21  repository reads, each given its own reason

Of those 21, several are not caching questions at all once read:
get_by_auth_code() and get_by_magic_token() are token lookups where a consumed
or revoked token must never come from cache; expire_overdue() reads the rows it
is about to mutate; search_user_by_cpf() is keyed on a per-request hash. Five
more are filtered admin lists whose cache key would be as varied as the query.
The remaining dozen are genuine candidates, named as such at the site and
tracked in a follow-up rather than papered over with a reason that implies all
is well.

Two placement traps, both caught by the gates rather than by inspection:

  - Inserting the directive between a docblock and its statement retargets any
    @PHPStan-Ignore in that docblock at the comment. PHPStan went 192 -> 194.
  - Moving it above the docblock instead aims the phpcs directive at the
    docblock, since it also covers only the next line — 14 sites went
    unsuppressed. The trailing form on the statement line is the only placement
    that leaves both pointing at the code.

Verified: the committed gate is clean over the whole project; the diff is
token-identical to HEAD ignoring whitespace and comments; PHPStan back to 192,
the same set.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…1030) (#1036)

First slice of the vacuous-test baseline. Tests only — no product code changed.

All 25 drove a guard and then asserted nothing:

    $_GET['_wpnonce'] = 'bad-nonce';
    Functions\when('wp_verify_nonce')->justReturn(false);
    ReregistrationSubmissionActions::handle_approve();
    $this->assertTrue(true);

Delete the nonce check from handle_approve() and that passes — it approves the
submission, redirects, and the test calls it a success. The same shape covered
every guard in the file, plus the capability check on ReregistrationAdmin.

Each now asserts the guarded effect did not happen. The handlers call the
writer and then wp_safe_redirect(), and read get_current_user_id() only to pass
to the writer, so neither call happening is the proof the guard held.
ReregistrationAdmin::handle_actions() is put in a state where it demonstrably
WOULD act — right page, a message queued — so add_settings_error() never being
called means the capability check stopped it. handle_save/handle_delete assert
the nonce is never verified and wp_die() never reached. The email handler says
outright what its comment had only reasoned about: the campaign query must not
run.

Verified the way #1030 asks for rather than by re-running the guard: the
protected check was deleted locally and the tests confirmed to fail. Removing
the capability check fails with "Method add_settings_error() should be called";
removing the handle_save action check fails naming wp_verify_nonce; removing
the approve nonce check fails on the writer being reached.

The expectations are repeated inline rather than factored into a helper.
AssertionCoverageTest reads each test method's own body, so a helper call is
invisible to it — the first attempt did exactly that and the baseline only
shrank by 6 instead of 25. Hiding an assertion from the guard that exists to
find missing assertions trades one false negative for another.

Baseline regenerated: 95 -> 70 entries.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
… (#1037)

Slice 2 of the vacuous-test baseline. Tests only — no product code changed.

Same shape as slice 1: each drove a nonce, capability or parameter guard and
then asserted nothing, so deleting the guard left them green. The handlers here
share a layout — the statement immediately after the parameter guard is the
destructive-capability check — which makes the assertion precise: a capability
that is never consulted is proof the guard returned. Ten SettingsActionHandler
tests now say that with shouldNotReceive( 'current_user_can_admin_or' ).

Where a guard could only be reached by a request that would otherwise act, the
request is set up to act: handle_cache_actions() is asked for warm_cache so the
capability is the only thing that can stop it, and the two
handle_submission_actions() tests queue a real trash action so the page and
capability checks are what the assertion is about, with the submission handler
asserted never to receive trash_submission().

Two were subtler than the rest, and both had the same fault. The geolocation
enqueue tests stubbed wp_enqueue_script() to throw and then asserted nothing:
the guarantee was real but lived 400 lines away in the stub, not in the test.
They now collect handles and assert the list is empty, like the sibling test
that checks the enqueue does happen. test_save_settings_calls_save_locations
was worse — its comment claimed save_settings no longer owns main_geo_areas and
then verified only that nothing threw; it now asserts an option was written and
that it carries no main_geo_areas key.

Verified by deletion, not by re-running the guard: removing the parameter check
from handle_migration_execution() and the manage-certificates check from
handle_submission_actions() each make their test fail.

Baseline regenerated: 70 -> 51 entries.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…) (#1038)

Slice 3 of the vacuous-test baseline. Tests only — no product code changed.

Three shapes here, and two of them were not guard tests at all:

  9 enqueue guards — wrong hook, wrong screen, wrong post type, wrong tab,
    non-singular, no post, no shortcode. Each called the method and asserted
    nothing, so the guard could be deleted and the test stayed green. They now
    collect what wp_enqueue_script/style receive and assert the list is empty.

  4 CPT guards — autosave, revision, non-publish, wrong post type. Past those
    guards sync_calendar_data() and cleanup_calendar_data() look the calendar
    up by post id before writing or deleting, so the repository never being
    consulted is the proof the guard returned. Asserted with the same overload
    mock the file's own create/update/no-record tests already use.

  3 registration tests — test_register_calendar_cpt_registers_post_type,
    test_add_submenu_pages_registers_menu, test_add_custom_metaboxes_registers
    _boxes. These are the ones worth calling out: each named a thing it did not
    check, so a method that registered nothing at all would have passed. They
    now collect the registered slugs and assert the registration happened.

The loader test is the inverse of its own sibling: wiring the admin trio is
what registers the appointments export source, so on the frontend it must not
be in the SourceRegistry — which the admin test already asserts positively.

Verified by deletion: removing the wp_is_post_revision() guard from
sync_calendar_data() makes its test fail.

Baseline regenerated: 51 -> 35 entries.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
)

Slice 4 of the vacuous-test baseline. Tests only — no product code changed.

Three findings beyond the usual "ends in assertTrue( true )":

  A test that passed for the wrong reason. The three
  handle_actions_returns_early_without_permission tests set the capability to
  false and called the handler with nothing in $_POST — so the handler would
  have done nothing whether or not the guard existed. They now queue the page's
  real save action and assert the nonce is never verified, which is what every
  acting branch does immediately after matching the action.

  And my own first pass repeated that mistake: it queued 'save_audience' on all
  three pages, but the calendar handles 'save_schedule' and the environment
  'save_environment'. Two of the three would have gone on passing for the wrong
  reason with better prose attached. Fixed to each page's real action.

  A test that never reached what it was named after.
  test_handle_global_holiday_actions_returns_early_without_permission: the
  capability check in that method sits INSIDE the add_global_holiday branch,
  after the nonce, so with no ffc_action in $_POST the method returned for an
  unrelated reason and the permission guard was never executed. It now queues
  the action and a valid nonce so the capability really is the last thing
  standing, and asserts nothing was persisted through update_option().

  Two tests whose names stated facts they did not check.
  test_register_rest_routes_creates_controller ("we just verify it doesn't
  throw") and the three shows_feedback_message tests: a register_rest_routes()
  that registered nothing, or a handler that surfaced no notice, would have
  passed. They now collect and assert.

Two mechanics worth recording. AudienceAdminPageTest could not alias-mock
Capabilities — it is already autoloaded by the time that test runs — so the
assertion goes through current_user_can(), which the helper delegates to.
And handle_csv_import() reads its three capability tiers BEFORE any action
branch, so a capability check proves nothing there; the nonce is the marker.

AudienceSampleCsvSourceTest is the weakest of the twelve and says so: the file
loads no Brain\Monkey, so no WordPress function is defined in it, and a gate of
any kind would fatal. It asserts the return contract; the environment supplies
the teeth.

Verified by deletion: removing the ffc_manage_audiences guard from
AudienceAdminAudience::handle_actions() makes its test fail.

Baseline regenerated: 35 -> 23 entries.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
Slices 5 and 6 together — the frontend group and the long tail. They are one
PR because the tail is by definition unrelated files with one entry each, and
splitting it would buy a CI cycle rather than a reviewer any clarity.

Tests only; no product code changed.

The routine half: nine enqueue/no-op guards now collect what they claim was not
enqueued and assert the list is empty; three "registers X" tests assert the
registration happened; two request handlers assert the query var was read
exactly once, since past the guard they read it twice more.

The part worth reading is where writing a real assertion proved me wrong.

  Csv::writer( $h )->close() does NOT close $h. My first assertion said it did.
  close() fcloses only a handle the writer owns, and Csv::writer() is handed an
  open one it does not — the caller keeps what the caller opened. The test now
  asserts that contract, which is the opposite of what I first wrote.

  Csv::reader_from_string()->all() returns positional rows, not header-keyed
  ones. I asserted header-keyed and it failed. The documented return is
  list<list<string>>.

Both had sat behind assertTrue( true ) for their whole lives, which is exactly
how a wrong belief survives: nothing ever contradicted it.

Two entries did not get an assertion, for different reasons:

  VerificationHandlerTest::test_magic_token_rate_limited_returns_error is
  DELETED. Its own comment said the real coverage is in RateLimiterTest (51
  tests, and the rate_limited path is there). The body constructed nothing and
  called nothing — a comment with an assertion attached, named after behaviour
  it did not exercise. Keeping it would preserve a false coverage signal.

  PluginActivationSmokeTest::test_foreign_key_migration_composes_when_version
  _stale STAYS in the baseline, now with the reason written at the site. Two
  observables were tried and neither separates the branches: the FK version
  stamp is not advanced in this environment, and $this->ddl records dbDelta()
  while the migration issues ALTER TABLE through $wpdb->query(). What it does
  establish is what its name says. Proving the ALTER statements belongs to the
  fresh-install job (#994), which has the database this does not.

Verified by deletion: removing the WP_Post guard from Frontend::frontend_assets()
makes its test fail.

Baseline: 23 -> 1 entry, and that one is explained.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
 (#1034) (#1042)

Comments only — the diff is token-identical to HEAD.

#1034 listed twelve uncached repository reads as caching candidates and named
UserService::get_user_statistics() "the strongest caching candidate found",
on the grounds that it runs three COUNT queries on every user-dashboard render.

Tracing the callers, that is wrong. get_user_statistics() is reached from
exactly two places, both cold: PrivacyExporters via export_personal_data() (a
GDPR export request) and UserCleanup via user_has_ffc_data() (the deleted_user
short-circuit). No dashboard render reaches it. Caching it would buy nothing,
and on the deletion path a stale "has data" answer would be actively wrong.

The premise came from counting call sites rather than reading them, in an issue
whose own text warned that "three queries per dashboard render is an argument,
not a measurement". The warning was right; I did not apply it to my own claim.

Two more dissolved the same way. get_by_reregistration_and_user() has three
callers that are separate AJAX entry points reading it once each, and a fourth
inside a loop keyed on a different campaign per iteration — a cache would never
be hit. get_audience_ids(), get_audiences(), count_by_audience(), get_by_key()
and count_by_reregistration() each have one or a handful of callers across
distinct flows, one call per request.

One survives, on a mechanical argument rather than a speculative one:
CustomFieldReader::get_by_audience() is the base read behind six sibling
accessors (_with_parents, _grouped, get_profile_fields, get_sensitive_fields,
_keyed), so a single render can repeat the same query for the same audience.
That repeat is what a cache would remove, and unlike the others it holds even
without a persistent object-cache backend. Still not implemented here: it needs
a measured render, and invalidation through CustomFieldWriter.

Every one of the twelve sites now states its verdict instead of promising a
follow-up. No site still says "tracked in the caching follow-up".


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…every survivor justified (#1035) (#1043)

Every annotation in the PHPCS scope was neutralised in place — rewritten to
name a sniff that cannot fire, never by renaming the `phpcs:` token itself,
which turns the directive into an ordinary comment — and the suite re-run. The
violations that surfaced were mapped back to the annotations covering them, and
that mapping decided each verdict.

phpcs:ignore 773 -> 434, phpcs:disable 128 -> 123, without a reason 260 -> 0,
naming a sniff that never fires 72 -> 0.

50 dead disable blocks and 15 duplicate ignore lines removed: the nonce blocks
sat in handlers that already call check_admin_referer/check_ajax_referer earlier
in the same function, and the template blocks named PrefixAllGlobals in partials
that only read their aliased variables. 375 DirectDatabaseQuery annotations
collapsed into 49 file-level disables, chosen by scanning every $wpdb table
reference and keeping only files where all of them are ffc_*; the 13 that also
touch wp_posts/wp_users/wp_usermeta keep per-line annotations.

The bug this found: PHPCS annotations are a flat on/off switch per sniff, not a
stack, so an inner `phpcs:enable X` ends an enclosing file-level disable at that
point. Live in AudienceReader, AudienceWriter, AudienceEnvironmentRepository and
AbstractRepository, with nothing red to show it.

tests/Unit/PhpcsSuppressionTest.php now fails on a bare annotation, one with no
reason, and one whose enable cancels an enclosing disable.

No behaviour changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ
…ts to 9 (#1044)

The batch accumulated five #1030 bullets that differ only in which slice of
the vacuous-test register they closed, three #1028 bullets on one audit, two
#1035 bullets and two #1024 bullets. Each issue now gets one bullet stating
where it ended up, which is what a release note is for — the linked PRs hold
the per-slice detail.

One number was wrong and is corrected here. The #1035 bullet claimed 373
annotations removed, taken from the PR title, which had summed classification
counts rather than removals. Measured across the merge commit, the tree went
from 964 annotations to 603: 361 gone. Trimming also brought every bullet back
under ~390 characters, from a worst case of 599.

No entry was dropped and every issue reference is preserved.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…1035) (#1045)

#1035 collapsed 375 per-line DirectDatabaseQuery annotations into 49
file-level disables. That trade is only honest while every table the file
touches is one of the plugin's own ffc_* — which is the test I ran by hand to
choose those files, and which nothing then enforced. A core-table query added
to any of them would have been silently unflagged, exactly the case the 13
mixed files were deliberately left per-line to avoid.

test_file_level_direct_query_disables_only_cover_plugin_tables() now runs that
same test on every CI: for each file whose disable covers the
DirectDatabaseQuery family, fail if it names a $wpdb core-table property or
builds a table from $wpdb->prefix with a non-ffc_ literal. Verified both ways —
adding a $wpdb->users read to AudienceReader fails it, and so does removing the
one allowlist entry.

That entry is MigrationForeignKeys, which is unavoidable rather than
overlooked: the foreign keys it creates all point at wp_users, so it has to
read that table's engine and name it in the REFERENCES clause. The reason sits
inline, as the AjaxWiringTest allowlists do.

It sees literals only, so a table name reached through a variable or a constant
is a false negative — acceptable for a deny-check whose job is to catch the
ordinary case of someone adding a core-table query.

CLAUDE.md gains the audit's durable findings, which until now lived only in
commit messages and issue comments: the two suppression guards and what each
fails on, the flat-switch behaviour of phpcs:enable, the file-level DDQ policy
and its scope gate, the neutralise-and-measure method for deadness (and why it
is out of CI), and the three parsing facts that cost the audit a parse error
and two wrong classifications.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…3 suppressions (#1035) (#1046)

Answers the question #1035 left open — not how to vigilate the suppressions
but how to stop needing them.

Core\RequestInput already made such annotations unnecessary (lines calling it
carried none while the raw $_GET read below did), but it only had
get_get_string. It gains the three readers it was missing: get_get_key
(sanitize_key, the right sanitiser for the slug-shaped page/tab/action/status
parameters of a wp-admin URL), get_get_int (mirrors get_post_int) and has_get
(presence without reading, since `?ffc_saved=` is a legitimate flag).

80 of the 96 GET call sites now route through them. Annotation lines 557 ->
484, sniff mentions 681 -> 603, NonceVerification 164 -> 92.

GET only, and that is the design. Routing a read through the helper removes
the warning, not the risk — RequestInput does not verify nonces. The 96
GET/REQUEST sites are graded Recommended by WPCS itself, where there was
never a nonce to check; the 76 POST sites are Missing, where the sniff is
working and the annotation is the record that a human checked. Those are
untouched.

The 16 left behind are deliberate: 14 $_REQUEST reads (it merges POST, so a
GET reader is the wrong tool), an empty() guard where '0' must read as
absent, and one raw read that needs the un-sanitised value.

Four converted sites are write handlers; each was read to confirm a
capability check plus check_admin_referer/wp_verify_nonce already guards it.
Three guard sites broke under a mechanical swap — has_get() hid the isset
from the possibly-undefined-index sniff — and were converted whole instead.

The tests that alias-mock RequestInput needed the new readers taught to them;
all 43 mocks now stub them faithfully, reading from $_GET so each test's own
setup keeps driving the assertion.

The 73 dead annotations were removed by measurement, not assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ
…ngs (#1035) (#1047)

rule can be switched off wholesale — phpcs.xml.dist, .stylelintrc.json,
phpstan.neon.dist and phpunit.xml.dist. Each entry there is a phpcs:disable
with repo-wide reach, so the same measure-first method was run over all of
them. The outcome is mostly "the exclusion was right", and the point of this
commit is that the verdicts are now recorded rather than folklore.

Two stylelint rules ON: declaration-block-no-shorthand-property-overrides and
declaration-block-no-redundant-longhand-properties. Both measured zero
findings, so they cost nothing today and guard a real bug class — a shorthand
silently wiping an earlier longhand. no-descending-specificity (92) and
no-duplicate-selectors stay off; all 4 duplicate hits are deliberate section
splits, a token block and a layout block under one selector, each commented.

Two phpcs rules stay OFF, and this is the finding worth keeping.
UnusedFunctionParameter reports 43, of which 33 are structural false
positives: 14 are parameters the method's included templates/*.php partial
consumes — the sniff cannot see across an include, and extracting markup into
templates/ is this project's own convention, so the architecture creates the
class — and 19 are signatures the caller fixes (WP hooks, a guard interface).
Enabling it would mean 33 new annotations, exactly the churn #1035 removed.
CommentedOutCode is 9 for 9 false: the heuristic reads an enum in a comment
("// 'daily' or 'span'") as code.

The 10 remaining unused parameters were read, and three were real dead
weight. return_to_draft() and bulk_return_to_draft() took a reviewer id while
the update sets reviewed_by to NULL — the docblock promised an attribution
the method deletes; collect_form_data() took a user id and reads $_POST.
Dropped, with their callers and tests. A fourth, RateLimitChecker::
check_verification()'s unused $token, is left alone deliberately: it is a
public method on a Security façade with five call sites, so removing a
parameter there is the deprecation-cycle case, not a cleanup. It is a dormant
per-token limit worth a decision, not a silent edit.

Two PHPStan excludePaths, includes/views and includes/libraries, are removed:
neither has ever existed in the history, and PHPStan's (?) optional-path
suffix meant nothing ever reported them. The surviving two are documented as
the markup-only carve-out they are, in step with the coverage scope — and
CLAUDE.md now records that includes/self-scheduling/views is deliberately NOT
excluded, so "a directory named views is markup" is not a repo-wide truth.

Both @ob_end_clean() loops are replaced by a level-guarded form. The naive
rewrite is worse than the original: while ( ob_get_level() > 0 ) spins
forever when a buffer cannot be removed (zlib compression, one PHP owns),
which is precisely what the @ plus the false return was handling. The form
used here checks the level and breaks on a failed close, so it terminates by
construction, drops the silence operator and the empty statement, and says
what the failure case is.

PHPCS green, stylelint green, PHPStan unchanged, 423 tests green.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
…#1049)

Surfaced by the unused-parameter measurement in #1035. The parameter is dead
at both ends: RateLimiter::check_verification() forwards a token that
RateLimitChecker::check_verification() never reads, and none of the five call
sites — four in VerificationHandler, one in SubmissionRestController — passes
one. Throttling is keyed on the IP alone, 10/hour and 30/day.

Deprecating rather than implementing, because the limit it advertises is not
needed. Keying on the token would only add defence against a distributed
brute-force of a single auth code — many IPs, each under the hourly ceiling,
all guessing one code — and the code space rules that out:
AuthCodeService::generate_auth_code() emits 12 characters over [A-Z0-9], so
36^12 ≈ 4.7e18 (~62 bits), with global uniqueness verified across the three
tables that hold one. At 30 attempts/day/IP, reaching 1% would need orders of
magnitude more source IPs than exist.

Leaving it is worse than removing it: a $token accepted and ignored on a
Security method advertises a per-token limit that does not exist, which is the
#936 shape — a signature that leads a reader, or a future caller, to assume a
protection that isn't there.

Deprecating rather than removing outright, because RateLimiter is a public
static façade and no code scan sees an external integration calling it with
two arguments. That is the case CLAUDE.md reserves the versioned cycle for,
and the #730 precedent (the success/fail keys on get_audit_log_summary()) is
the same shape: zero internal consumers, invisible to any diagnostic, retired
by announcement. Removal lands in 6.24.0, the 2nd feature release after this
notice, matching that precedent.

Both docblocks now say the argument is ignored, name the removal version and
the issue, and the checker's records why a per-token limit is not warranted —
plus the trigger that would reverse the decision: implement it instead if the
auth code ever shortens, or a flow appears that accepts a short human-typed
code.

Docblocks and CHANGELOG only; no signature or behaviour change.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
Bumps FFC_VERSION in the three sync sites (plugin header, constant,
readme.txt Stable tag) and renames the [Unreleased] heading to
[6.22.0] (2026-09-04), opening a fresh empty [Unreleased] above it.

No source change. The batch is the #1027/#1028/#1030/#1034/#1035
suppression-audit arc plus the #865 seeder fix, the #1022 update-screen
compatibility fields, the #1024 activity-log labels and the #1048
deprecation notice. The cache key also rotates for the pt_BR language
update that landed after 6.21.0.

The release commit's short SHA is backfilled onto the [6.22.0] heading
in a later PR, after develop is reset to main.


Claude-Session: https://claude.ai/code/session_01D1wR59A8Z7d3QGYnm8q2KQ

Co-authored-by: Claude <noreply@anthropic.com>
@coveralls

coveralls commented Sep 4, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 33856860715

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit aba5aec on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 89.925%

Details

  • Patch coverage: Could not be determined — this PR's diff is too large for GitHub to return (406 error at GitHub).

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 53409
Covered Lines: 48028
Line Coverage: 89.92%
Coverage Strength: 4.79 hits per line

💛 - Coveralls

@rpgmem
rpgmem marked this pull request as ready for review September 4, 2026 09:08
@rpgmem
rpgmem enabled auto-merge (squash) September 4, 2026 09:08
@rpgmem
rpgmem merged commit aa5ba5b into main Sep 4, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants