Releases: johnjanney/bogo-select
Release list
BOGO Select for WooCommerce 2.3.10
Install: download bogo-select-2.3.10.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.10] — 2026-08-17
Compatibility metadata only. No functional change: every runtime file in the
archive is byte-identical to 2.3.9 except bogo-select.php, which differs in
the version string and the WC tested up to header. The README and this
changelog change with it and nothing else in the archive does. Upgrading changes
nothing a customer can see.
Changed
-
WC tested up toadvanced from 10.9 to 11.0, and the README's tested-with
row from 10.9.4 to 11.0.1. The header was not wrong so much as behind: the
integration matrix has been running against WooCommercelatestall along, so
the plugin was claiming less than it had evidence for. WordPress.org shows that
field to people deciding whether a plugin is current, and understating it is
its own kind of inaccuracy.The claim is made on the same terms 1.2.1 set, which declined to advance this
header without a matrix to justify it. Run32071013547installed
WooCommerce 11.0.1 and exercised the plugin from the built zip rather
than from source, in a real browser: the block integration test, classic cart
and checkout, the coupon and discounted-reward and variable-reward lanes, the
phone viewport, the settings-screen role test, a real order placed and its
metadata and stock asserted, and a step that fails the run on any PHP notice
from the plugin. The 9.9.5 compatibility floor passed in the same run.Only the tested ceiling moves.
WC requires at leaststays at 9.9 and the
floor lane still runs, so nothing about the supported range narrows.
BOGO Select for WooCommerce 2.3.9
Install: download bogo-select-2.3.9.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.9] — 2026-08-17
A price the browser stopped parsing twice, and the release machinery that had
been waiting for something to ride out with.
The shipped change is one CodeQL finding. A variation's price travelled to the
browser as markup on an attribute and the selector wrote it back with
innerHTML, so the figure was parsed into HTML a second time — past the filter
the server had already run over it. Nothing a customer can type reaches that
markup, so this is a PATCH and not an emergency; what it corrects is that the
same string had a filtered path and an unfiltered one and only one was checked.
Everything else here is the release process and its safety nets, accumulated
since 2.3.8 and held back because tooling does not bump a version on its own
(BRIEF.md §8.1). This is the release they ride out with.
Added
-
A check that the suite would object to a real defect, and the hole it
found on its first run.bin/verify-tests.shreintroduces eight defects this
plugin actually had — the Buy list ignoring a variation's own ID, a gift line
counting toward another gift, a reversed schedule saving anyway, a date read
with trailing junk, a Shop Manager unable to save, the summary counting list
entries, a search loading every candidate twice, an array becoming product 1 —
and requires the unit suite to fail on each.It exists because a green suite says the tests agree with the code, not that
they would object to different code, and those are separate claims. v2.3.1
shipped a browser assertion that passed on a negative true either way, for two
releases.Seven were caught. The eighth survived: the v2.3.7 fix had no test. That
release refused a non-scalar where a product ID belongs, and reverting it
changed nothing the suite noticed — behaviour described in a changelog and
guarded by nothing. Four tests cover it now.It runs as its own CI job on one PHP version, since whether a test notices a
defect does not vary by interpreter. -
The same check for the browser assertions, which is the half that matters
more: the browser layer is where the vacuous assertion actually shipped.
bin/verify-browser-tests.shruns inside the integration job, reusing the
stack it has already built, and copies each mutated file straight into the
installed plugin — standing up WordPress per mutation is not affordable.Five defects: the phone layout's touch targets shrinking back to what they
were before 2.3.5, a gift card ceasing to be a row, the chooser's listeners
leaving the document as they were before 2.2.1, and the two settings-screen
refusals M-01 and M-02 turn on.Every target test runs before its mutation and must pass. Without that a
broken stack would report every mutation as caught, which is the most
flattering possible way for a check like this to be useless. -
A release gate that refuses to publish a tag CI did not pass.
bin/verify-ci.shresolves a tag to its commit, checks the tag on origin
points at the same commit, finds the CI run for that SHA, waits if it is
still going, and exits non-zero unless it concludedsuccess. It is now step
2 ofBRIEF.md§8.4, between pushing the tag and publishing the release.Two tags had already gone out red. v2.3.1's integration lanes failed on an
assertion introduced in the same release; v2.3.8's coding-standard job failed
on a docblock. Both were found afterwards, by hand, and only because someone
went looking. The script was checked against both: it refuses each and names
the jobs that failed..github/workflows/release-gate.ymlruns the same script on
release: published, so a release cut by any route — the web UI, a direct
gh release create, someone else's hands — is checked even though the
process step was skipped. It reports and does not unpublish: v2.3.8's red run
was a docblock alignment in a comment, and withdrawing a sound archive over
that would have been the larger harm. What to do about a red release is a
judgement, and the workflow's job is to make sure it is offered rather than
missed.The manual check it replaces was worse than no check. It asked for "the most
recent run" moments after pushing, which is frequently the previous
commit's — so it could answer green for a commit that was never tested, in a
form that read as verification. Runs are matched by SHA and by nothing else,
and a commit with no run at all is refused rather than assumed fine, which is
what v1.0.0 would be: tagged before the pipeline existed. -
The 2.3.x line summarised in Simplified Technical English, and kept out of
the archive.RELEASE-SUMMARY-2.3.x-STE.mdrestates 2.3.1 to 2.3.8 in
ASD-STE100: one section per release, a limited vocabulary, and short
sentences. The entries below are each written at the length their release
deserved, which is the right form for the record and the wrong one for a
reader who wants to know what eight patch releases did. It adds nothing to
them, and where the two disagree this file is right — including 2.3.8's
correction of the two claims 2.3.7 shipped with, which the summary repeats as
a correction rather than quietly fixing in its 2.3.7 section.It does not ship.
'RELEASE-SUMMARY*.md'is inPACKAGE_EXCLUDESbeside
CODEX-REVIEW*.md, because the archive already carries this changelog, and a
second shorter account of the same releases gives a store two documents that
can disagree — with the summary the one that goes stale first. A pattern
rather than a filename, so the exclusion holds for the next summary without
anyone remembering it.It would otherwise have shipped, and that is the manifest working as written
rather than an oversight. The list names what stays out and packages
everything else, so a new runtime file is packaged and verified without
being named anywhere — which is the property that stopped the build and the
verifier drifting apart. The cost is the other direction: a document meant for
the repository alone has to be excluded on purpose. Checked by reading the
list rather than by trusting the edit — the manifest returns the same 23 files
as before the summary landed, andbin/verify-zip-check.shstill catches all
six ways an archive can be wrong.
Fixed
-
CI was red on the v2.3.8 tag, from a docblock the coding standard would not
accept. The level-9 work spelled a variation option's shape out inline, and
at 85 characters the standard's parameter alignment rule wanted the next line
indented to match it. The shape is named once on the class now and referred to
by that name in the three places that used it, which is shorter to read than
either the inline type or the alignment it demanded.It reached the tag because the release checks ran
phpcs | tail -3, and a
summary report's last three lines are a separator and a timing — the findings
above them were cut off, so a failing run read as a passing one. The same
shape of mistake as the archive that reported "87 runtime files verified".
Checks are read by exit code now, not by the tail of their output.No runtime file changed and the published 2.3.8 archive is unaffected.
-
The archive check now reads every entry in the archive
(CODEX-REVIEW.mdL-03, the last of that finding's recommendations). It
compared.php,.js, and.cssand nothing else, so of the 28 entries in
the 2.3.8 archive it looked at 14. The changelog, the brief, the decision
record, and the licence shipped in every release unread — a stale or
substituted document was outside what the gate could see, and the gate said
"matches the worktree" regardless.Three questions are asked of each entry now, rather than one of some of them:
it must be a file the worktree has, with an identical SHA-256, that the build
was meant to ship. The last of those is new and catches what the other two
cannot — anode_modules/or acomposer.lockinside an archive is a defect
even when it matches the worktree perfectly, which is exactly how the 2.3.5
archive passed.Entries are read from the archive with
unzip -Z1rather than by walking the
unpacked tree, because a file installed outsidebogo-select/would unpack
outside the directory a walk of that directory can reach. -
The build and the verifier now read one list.
bin/package-manifest.sh
holds the exclusions;build-zip.shcopies what it returns and
verify-zip.shchecks the archive against the same thing. They used to keep
a list each — one inrsync --excludeflags, one in afindprune, and a
third in the no-rsync fallback — which is whynode_modulescould be added to
the build and missed by the check.The build is an allowlist now: it copies the files the manifest returns
instead of copying everything and deleting afterwards, and the-x '.*'
filter at zip time is gone, since a second filter after the list is decided is
another place for the two to disagree. The 2.3.8 archive rebuilt this way
contains the same 28 entries as the published one.Both directions were checked against a deliberately wrong archive: drifted
CHANGELOG.mdandLICENSEare reported stale, a removed file missing, a
smuggledcomposer.lockandnode_modules/excluded, an unknown file extra,
and an entry outsidebogo-select/out of tree. -
The parity gate is now required to object, the way the suites are.
bin/verify-zip-check.shbuilds real archives in a temporary sandbox, breaks
each one of those six ways, and requiresverify-zip.shto fail and to name
the reason — a non-zero exit for the wrong reason is not a catch, since a
gate that rejects everything is as useless as one that rejects nothing and
reads the same from outside. It runs in CI beside the parity check, and ne...
BOGO Select for WooCommerce 2.3.8
Install: download bogo-select-2.3.8.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.8] — 2026-08-02
Level 9, and a correction to why 2.3.7 said it was out of reach.
The analyser now runs at its maximum with no baseline and nothing suppressed.
Getting there was mostly declaring shapes that were already true rather than
guarding against values that could not occur. The runtime change is that a cart
line's numbers are read through the same normaliser as request input instead of
cast, so an array refuses where it used to become 1 — behaviour changes only for
input that was never valid, which makes this a PATCH.
Changed
-
Static analysis raised to level 9, and the reason 2.3.7 gave for stopping
was wrong. That entry said the level was not worth taking, on two
conclusions that do not survive checking. Both are corrected here rather than
in place, since 2.3.7 shipped with them.It said threading the settings shape through the admin sanitizer made things
worse, 41 findings becoming 80. It did not. The@phpstan-import-typeline
had never reached the file — the script meant to add it aborted before
writing — soBogoSettingsresolved to nothing and every read of it became
"access to an offset on an unknown class". With the import actually present,
the same change removes 21 findings.It said the cart-item casts needed eighteen
is_scalar()branches no cart
WooCommerce builds could reach. Most of them wanted a shape declared instead:
state()andvariation_options()both build arrays with known keys and now
say so. The reads that genuinely come from a cart line — which any extension
may add to — go through the sameto_id()helper as any other untrusted
value, which is shorter than the cast it replaced and refuses an array where
the cast would have produced 1.Nothing was suppressed and there is still no baseline. What changed is that a
tool reporting more errors after a change is evidence about the change, and
it had been read as a verdict on it.
BOGO Select for WooCommerce 2.3.7
Install: download bogo-select-2.3.7.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.7] — 2026-08-02
An array where an ID belonged.
Attempting PHPStan level 9 found input handling that read a non-scalar as
product 1. The level is not taken — the reasoning is in phpstan.neon.dist —
but what it found on the way is worth shipping. Behaviour changes only for
input that was never valid, so this is a PATCH.
Fixed
-
A value that is not a scalar is no longer read as product 1.
absint()
reaches forintval(), andintval()of a non-empty array is 1 — so an
array arriving where an ID was expected became a reference to whatever
product holds ID 1, rather than nothing. That was reachable from a
hand-edited option row (buy_productsholding a nested array) and from a
request sendingproduct_id[]=7instead ofproduct_id=7. Everything that
turns submitted data into an ID now checks it is a scalar first — in the
settings normaliser through a shared helper, and at the request boundaries
as a visible guard beside theabsint( wp_unslash() )the coding standard
recognises.Found by attempting PHPStan level 9, which is the level that objects to
mixedbeing passed around. The objection was right about these.
Changed
-
The settings row has a declared shape, and it is checked.
all()
normalises all fourteen keys on the way out, so what a caller receives has
always been a known type — nothing said so, and every reader cast the value
again to be sure. The shape is now stated,get()returns a type per key,
andall()builds one array rather than amending a merged one, so a key
added to the defaults and forgotten in the normaliser is a hole the analyser
reports instead of a value that reaches a caller in whatever type the
database held.Stating it immediately found three places passing an
intor afloatto
esc_attr(), which expects a string. That is the same class of thing level 5
found a year of releases ago, and it was invisible while the type was
mixed. -
Level 9 was attempted and is not taken, with the reasoning recorded in
phpstan.neon.distrather than left as a shrug. After the fixes above it
still reports 40, of which 18 are(int) $cart_item['product_id']and its
like: a WooCommerce cart item is an array any extension may add to, so its
values genuinely aremixedand the cast is the correct handling rather than
the defect. Satisfying the rule means anis_scalar()guard at each, which
is 18 branches no cart WooCommerce builds can reach. The remainder needs the
settings shape threaded through the admin sanitizer — which was tried, and
made the count go up, because every intermediate step then has to
preserve a shape it is in the middle of editing.The level stays where the code passes with no baseline and nothing
suppressed, which is the same rule it has been held to since 2.3.2.
BOGO Select for WooCommerce 2.3.6
Install: download bogo-select-2.3.6.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.6] — 2026-08-02
The measurement, and what it turned out to be worth.
CODEX-REVIEW.md M-03 asked for a benchmark before any latency claim was
published. Running it found a broad gift search costing 612 database queries on
a 2,000-product catalogue; fixing what it found brought that to 15. The runtime
change is one batched fetch — behaviour is identical and only the cost moved,
which makes this a PATCH.
Changed
-
A page of gift choices is fetched in one batch instead of one product at a
time (CODEX-REVIEW.mdM-03). The benchmark below found a broad All
Products search costing 612 database queries against 2,000 products — about
three per candidate, because eachwc_get_product()found its own way to the
post row, its meta, and itsproduct_typeandproduct_visibilityterms.
2.3.1's request memo stops the same product being loaded twice inside a
request and does nothing about the request being the first one, which on a
store without a persistent object cache is every request._prime_post_caches()now asks for all of it once, before the eligibility
loop starts. Re-measured on the same catalogue: 612 queries → 15 for a
broad search, 1,508 → 11 for the curated list's cold eligibility build,
81 → 12 for browsing a page. A single-SKU search is unchanged at 12,
which is the guard working — a batch of one is skipped, since priming it
would cost a query to save none.Nothing downstream changed: the same products are loaded, by the same calls,
in the same order. This is not fewer loads, it is the same loads costing
fewer queries — which is why the unit suite's product-load counts are
untouched, and why the cost needed a benchmark to see at all.M-03 floated a result cache keyed by search term and catalogue state if
object reuse turned out not to be enough. This is the cheaper answer to the
same measurement: no new cache to invalidate, no key to get wrong, nothing to
go stale.
Added
-
A large-catalogue benchmark, and the numbers from running it
(CODEX-REVIEW.mdM-03). It asked for wall time, database queries, CPU, and
peak memory measured before any latency claim is published, and was explicit
that the product-load counts the unit suite holds are not latency. These are
seconds, queries, and bytes; the loads stay where they were.On 2,000 products with 500 curated, no persistent object cache: a broad All
Products search costs 0.23s and 612 queries cold and 0 queries warm; the
curated list's cold eligibility build costs 0.48s and 1,508 queries, carried
by a transient so a store pays it once per ten minutes rather than per
request; browsing a page costs 0.03s and 81 queries.The warm column is 2.3.1's request memo working — zero queries on the second
call, every path. The cold column was the finding: about three queries per
candidate, because the memo stops a product being loaded twice in a request
and does nothing about the request being the first. That is now fixed — see
below — and both sets of numbers are recorded inCODEX-REVIEW-RESPONSE.md
with what they do and do not say.Its own workflow, on
workflow_dispatch. Seeding takes about a minute and
the numbers are for reading rather than gating; a threshold on a shared runner
would mostly measure the runner. -
The settings screen is exercised through
options.phpunder a real role
(CODEX-REVIEW.mdL-02, and the regression test M-02 asked for).
AdminSettingsTest.phpcalls the same sanitize callback WordPress calls, and
structurally cannot reach what happens before it: the nonce, the option
allowlist, and the capability check. That check was the whole of M-02 — a Shop
Manager could fill the form in and be refused on submit.A Shop Manager now signs in, opens the page, saves a schedule, and the form is
read back to prove what was stored. An Editor is refused the page. A malformed
date and a reversed window are submitted and the previous schedule is shown to
have survived both — reading the form rather than the message, since a screen
that showed an error and stored the value anyway is exactly what M-01 was.The site runs on a non-UTC clock for it, and the dates come from the store's
owncurrent_time(), soDECISION.mdD-019's "whole days in the site's
timezone" is exercised rather than assumed: an offer ending today is not
called expired, and one ending yesterday is.
BOGO Select for WooCommerce 2.3.5
Install: download bogo-select-2.3.5.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.5] — 2026-08-02
A test written to close a gap, which then found something in it.
Most of this release is CI and tooling, and on its own none of it would have
earned a version — BRIEF.md §8.1 gives the version to the plugin code. The
phone-viewport test changed that by failing: the chooser's buttons were 21 CSS
pixels tall on a phone, which is a real defect a real customer has been tapping
at since 2.2.0. The fix for it is what makes this a PATCH rather than another
entry under Unreleased.
Fixed
-
The build no longer packages
node_modules, and the parity check no longer
says it did not. Committing a lockfile in this release means a developer who
has runnpm cihas anode_modules/directory, andbuild-zip.shhad no
reason to exclude one before — so cutting this release produced an archive of
4.1MB and 180 files instead of 136KB and 23, carrying 197 files of Playwright
into stores.The worse half is that
verify-zip.shpassed it. That script exists to
catch exactly this (CODEX-REVIEW.mdM-01, the v1.2.0 archive that shipped a
superseded class). It compares runtime files on both sides while pruning the
directories the build excludes, andnode_moduleswas in neither list — so it
found the same stray.jsfiles in the worktree and in the archive, called
them matching, and reported "87 runtime files verified": six times the real
number, in the message whose whole job is to be trusted. Both scripts now
prune it, and the archive was checked by reading its contents rather than by
trusting the exclude list.CI could not have caught this, because the package job never installs npm
dependencies and its archive was always clean. Only a developer's own build
was affected, and only since the lockfile landed — introduced and found inside
the same unreleased window.
Changed
-
Everything CI reaches for is pinned, and Dependabot watches the pins
(CODEX-REVIEW.mdL-03). Actions are pinned to full commit SHAs with the
version in a comment beside each — a tag can be moved, a SHA cannot. The
integration job's browser now comes fromnpm ciagainst a committed
package-lock.json, so it drives the Playwright that file names by integrity
hash instead of whatevernpm installresolved that morning..github/dependabot.ymlis the half that makes the rest safe, and is why
this was not done earlier. Pinning on its own trades a supply-chain risk for
a staleness risk: pinned actions stop receiving security fixes and nothing
says so. Weekly updates now cover the actions, the Composer dev tools,
Playwright, and the integration containers.Container images stay pinned by tag rather than digest. They exist for the
length of one CI job and are never published or deployed, so what matters is
being told when a newer WordPress or MariaDB appears — the same signal that
keeps the compatibility matrix honest.Nothing here can reach a store: the shipped plugin has no Composer or npm
runtime dependency, and neither manifest is in the archive. -
The first five updates Dependabot proposed, all merged.
actions/checkout5.1.0 → 7.0.1,actions/setup-node5.0.0 → 7.0.0,
actions/upload-artifact4.6.2 → 7.0.1, Playwright 1.56.0 → 1.62.0, and
MariaDB 10.11 → 12.3 in the integration containers. The pinning did not cause
that drift, it revealed it — three of the actions were majors behind before
anything was pinned.The three action majors share one cause: each moved its own runtime to Node 24
at v6, which needs Actions Runner 2.327.1 or newer, andubuntu-latest
satisfies that.actions/checkoutv7 also refuses to check out a fork's PR
head underpull_request_targetandworkflow_run; this workflow triggers on
pushandpull_requestonly.MariaDB was the jump with the most behind it:
order.test.mjsplaces a real
order and reads back its line metadata and stock decrements, and all eleven of
its checks passed on 12.3. The plugin issues no SQL of its own — every query in
the repository is in a test fixture — so the database is infrastructure for the
job rather than something the job certifies. The one cost is representativeness,
since CI now runs a newer database than most stores do. That is acceptable while
the plugin writes no SQL; if it ever does, the answer is a deliberate pin to an
LTS or a second lane rather than letting this drift back by accident. -
The compact chooser is checked at a phone width (
CODEX-REVIEW.mdL-02).
The layout added in v2.2.0 applies below 600px and the integration browser
runs at 1280px, so every browser assertion ever made about the chooser was
made at a width where the rule does not apply.mobile.test.mjsrenders both
carts at 390×844 and measures the boxes: the thumbnail capped and beside the
text rather than above it, the button under the name, no card running off the
side, and buttons meeting the WCAG 2.2 minimum target size.Geometry rather than screenshots, because a screenshot proves a layout
changed and says nothing about whether it changed correctly. It also taps a
gift, since a card that measures perfectly and cannot be tapped — something
invisible over it — is the failure worth catching, and Playwright's
actionability check is exactly that assertion.It found a defect on its first run. Every geometry assertion passed — the
layout is the row it was meant to be — but the buttons measured 21×53 CSS
pixels, under the 24px WCAG 2.2 asks for and well under what a thumb wants.
On a phone these are tapped rather than pointed at, so the compact layout now
gives its controls a 44px minimum target, the size the platform guidelines
settled on. "Remove gift" is styled as text rather than as a button and is
tapped just the same, so it gets the same target without gaining a border:
its background stays transparent, and the extra height is reach rather than
anything the customer sees. Cards grew from 134px to still well inside the
bound the test holds them to.Overflow is asserted against the chooser and its cards rather than the
document. A page-level check would fail on the theme's layout or
WooCommerce's own blocks, neither of which this plugin can fix, and a check
that fails for someone else's reasons is one that gets switched off. A real
page-level overflow is still printed to the log.
BOGO Select for WooCommerce 2.3.4
Install: download bogo-select-2.3.4.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.4] — 2026-08-02
A second opinion, and the one thing it saw that the first could not.
What is added is development tooling and is not in the archive, so this is a
PATCH: the runtime changes are whitespace, a post-increment made a
pre-increment, docblock wording, and a translator comment that had been
describing the wrong string. Every offer behaves as it did in 2.3.3.
Added
-
WordPress Coding Standards, with the shipped plugin passing in full
(CODEX-REVIEW.mdL-04, the half PHPStan does not cover). PHPCS with the
WordPressstandard runs fromcomposer sniffand in the same CI job as the
analyser. The two barely overlap: one checks types, the other formatting,
escaping, prefixing, and i18n.The first run reported 422 errors across 42 files, of which the shipped
plugin —includes/,bogo-select.php,uninstall.php— accounted for 42.
It now passes the full standard with nothing excluded for it alone.One finding was a real, if small, defect that nothing else would have caught:
the shop notice carried two translator comments, the first describing a
two-placeholder version of a string that has taken three since the reward
gained a configurable name. A translator reading the file would have been told
the wrong thing about the string directly beneath it. The stale one is gone.The uninstall script also stopped leaving
$site_idsand$site_idin the
global scope it runs in, and two integration fixtures stopped shadowing
WordPress's own$orderand$mode.Everything excluded is excluded with its reason beside it in
.phpcs.xml.dist, and there is no baseline.manage_woocommerceis declared
as a known capability rather than the sniff being switched off, so it still
catches a mistyped one. Exception messages in the Store API path are left
unescaped deliberately: that response is JSON, and escaping would send an
apostrophe to the customer as'.tests/is held to the standard with four documented exceptions, each a
convention test code follows and shipped code does not — a stub must carry the
WordPress name it stands in for, a test's name is its documentation, the fake
catalogue is one file describing one thing, and the integration fixtures query
a disposable container directly.
BOGO Select for WooCommerce 2.3.3
Install: download bogo-select-2.3.3.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.3] — 2026-08-02
Three more levels of the analyser, and the one thing they found.
Levels 6 and 7 changed documentation only. Level 8 changed runtime code, which
is why this is a release rather than three annotation commits: null and
false were both being used to mean "no product", and one of them has stopped.
Behaviour is identical — every caller was testing truthiness — so this is a
PATCH.
Changed
-
Static analysis raised to level 8, which checks what becomes of a null.
Eighteen findings, and this level touched runtime code where 6 and 7 had not.wc_get_product()has two ways of saying "there is no product":falseand
null. Three functions passed both straight through while declaring only
WC_Product|false, and every one of their callers was doing a truthiness
test anyway — so the distinction had never meant anything to anyone. They now
fold it at the boundary and return one falsy answer. Behaviour is unchanged,
sincenullandfalseare both falsy and no caller ever compared strictly;
what changes is that a caller now has one absent case to handle instead of
two.The rest were places where the analyser could not see that execution stops.
BOGO_Select_Ajax::fail()andBOGO_Select_Blocks::error()end the request —
one sends a JSON error, the other throws — and both were documented as
returningvoid, so everything after a call to them looked reachable and the
guard above it looked pointless. Both arenevernow, which resolved
thirteen findings between them. -
Static analysis raised to level 7. Level 7 checks that a union type is
narrowed before it is used, and it found seven places where
wc_get_product()returningWC_Product|falsehad gone unnoticed. All seven
were the same story with two different endings.Three are functions that deliberately answer for the
false— a deleted
product is "no longer available", and claims no stock — while their signatures
claimed to require aWC_Product. Each one's very first line is the guard
that handles it. The documentation was understating the code, so the
documentation changed and nothing else did.The other four all trace back to
is_offerable_variation(), whosetrue
answer is proof there is a product, since it starts with aninstanceof.
Nothing said so, and callers went on to use the result without asking again —
correct, but resting on an invariant no tool could see and nothing recorded.
A@phpstan-assert-if-truestates it once, in the one place that establishes
it. Removing that line brings all four findings straight back, which is how it
was checked rather than assumed. -
Static analysis raised to level 6, which is the step 2.3.2 named as next.
Everyarrayin a docblock now says what it holds and every method declares a
return type: 34voiddeclarations and 46 array types, still with no baseline
and nothing suppressed. No defect was found — that was the prediction, and it
held.Three of the array types are shapes rather than
array<string,mixed>, and
those are the ones worth more than documentation.get_choice_page()and the
two methods behind it declare
array{ids: int[], page: int, pages: int, total: int}, which the analyser
checks against what they actually return, so a key added, renamed, or dropped
is an error rather than something the chooser discovers later. The other 43
are honestly open-ended: a settings row and a WooCommerce cart item have no
fixed shape, and inventing one would document a guess.
BOGO Select for WooCommerce 2.3.2
Install: download bogo-select-2.3.2.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.2] — 2026-08-02
An analyser, and the twelve findings it was wrong about.
What is added is development tooling rather than plugin functionality, so this
is a PATCH: the runtime changes it prompted are casts and annotations, and every
offer behaves exactly as it did in 2.3.1.
Added
-
Static analysis, at a level the code actually passes (
CODEX-REVIEW.md
L-04, the half deferred from 2.3.1). PHPStan reads the runtime code against
the WordPress and WooCommerce stub packages and judges it as PHP 7.4, the
compatibility floor, while running on a current PHP.composer analyseruns
it and so does a CI job of its own. There is no baseline and nothing is
suppressed: a baseline records what the code got wrong and then stops
mentioning it, which turns the level into a number about history rather than
about the code.The first run reported 116 problems. Eighty were missing type annotations —
the level-6 rules, no defect among them, and the named next step. Of the rest:
seven passed an int toesc_attr(), six used plugin constants the analyser
cannot see because it does not rundefine(), three crossed float and string
on a price, and two calledget_variation_attributes()on aWC_Product,
where it does not exist. All are fixed; none changed behaviour.Twelve were reported as redundant guards and were kept. The stubs describe
current WooCommerce optimistically, so amethod_exists()check against an
older release reads as always true and is not — deleting those is how a
compatibility guard becomes a regression.treatPhpDocTypesAsCertain: false
stops docblock types being treated as certainties while leaving native types
alone, and the twelve findings went away without the code going with them.
Fixed
- The pinned-sibling browser assertion matches what WooCommerce renders.
Tightening it in 2.3.1 turned it red: it looked for the variation's full post
title,Classic Variable Thing - Large, in the cart line. WooCommerce renders
a variation line as the parent's name withSize: Largebeneath it, so that
string is never there. The 2.3.1 CI failure was this assertion, not the
plugin — and it also showed the assertion it replaced had been passing on a
negative that was true whichever sibling was in the cart. It now matches the
attribute, still scoped to the cart rows, and prints the row text when it
fails.
BOGO Select for WooCommerce 2.3.1
Install: download bogo-select-2.3.1.zip below, then WordPress → Plugins → Add New → Upload Plugin.
[2.3.1] — 2026-08-01
A settings screen that said no and meant it.
Answers the sixth Codex review (CODEX-REVIEW-RESPONSE.md Part 0).
Fixed
-
The settings screen refuses a schedule it cannot honour, instead of
describing one and saving it anyway (CODEX-REVIEW.mdM-01). A window
running from the 20th to the 10th produced the error "so it will never run"
and was then stored exactly as typed:add_settings_error()draws a message,
and WordPress writes the option regardless. Every check the screen made was
advisory, and two of them — this changelog at 1.3.0, and Q-005 — said
otherwise. A reversed window now leaves the stored schedule in place, and the
message names the schedule that survived. A date the screen cannot read does
the same rather than clearing the bound: an empty field means "no bound"
because a store asked for one, while a typo is a store asking for a bound and
missing, and reading the second as the first is the one mistake that widens a
campaign meant to be narrowed. Everything else in the same submission still
saves. The reversed-window check no longer runs only for enabled offers, so a
window that can never run cannot be parked on a disabled one and switched on
later. -
2026-08-01junkis no longer read as the first of August. Each
dash-separated part was converted withintval(), which stops at the first
character it cannot read, so a value that looks nothing like a date became a
real schedule boundary. The whole string must now be a date. Unpadded parts
such as2026-8-1still work. -
A Shop Manager can save the settings page they can already open
(CODEX-REVIEW.mdM-02). The menu and the page both ask for
manage_woocommerce, which is the capability WooCommerce gives that role, but
options.phpasks formanage_optionsunless the option group says
otherwise — so the intended operator could fill the form in and be turned away
on submit. One capability now governs both halves. -
The offer summary counts selections rather than list entries
(CODEX-REVIEW.mdL-05). A Buy list holding a product and one of its own
variations reported "2 selected products", when the second selects nothing the
first had not already selected. The stored list is unchanged — it is what the
store typed — and only the sentence describing it was wrong. -
Documentation that had drifted from the code (
CODEX-REVIEW.mdL-01). The
README describedbogo_select_reward_addedas taking two arguments when it
has sent three since variations landed, so a callback written from the README
never received the one that says which variation was given. It also called
shipping untested, and the brief still asked for a manual staging pass; both
have been covered by CI since v2.1.0 and v1.3.0 respectively.ScheduleTest.php
was missing from the test inventory.
Changed
-
One gift search loads each candidate once instead of twice
(CODEX-REVIEW.mdM-03). A search judged every match for eligibility, loading
each product, then sorted the survivors by name, loading each again — 120
product loads for 60 candidates, and up to 400 at the 200-match ceiling, to
render 24 cards. A per-request memo sits behind both passes. It holds only
facts that cannot change inside one request; stock is deliberately not among
them, so a reward added mid-request is still judged against a freshly loaded
product. A test holds the ratio, and another asserts the memo does not outlive
the cache flush that clears the variation memo beside it. -
CI fails on PHP notices raised by this plugin (
CODEX-REVIEW.mdL-02).
The brief has asked for a clean log underWP_DEBUGsince 1.0.0 and nothing
had ever checked. The integration job now turnsWP_DEBUGon before the
plugin is installed and fails on any logged line naming a file of ours;
notices from WordPress and WooCommerce themselves are printed and ignored,
since no change here can fix them. The workflow also declares
permissions: contents: read, so the token's reach is versioned with the code
rather than held in a repository setting (CODEX-REVIEW.mdL-03). -
The settings screen has tests. It had none, which is how a screen that
said it refused a schedule went on saving it.AdminSettingsTest.phpasserts
what the sanitizer returns and not merely what it says, because a test that
read the error message would have passed against the broken code. -
The pinned-sibling browser assertion checks the card it means
(CODEX-REVIEW.mdL-02). It computed whether the large variation was selected
and then never asserted it, falling back to searching the whole page for
"Large" — which appears in the chooser's own options either way, so it passed
whether or not the swap happened.