Skip to content

Releases: johnjanney/bogo-select

BOGO Select for WooCommerce 2.3.10

Choose a tag to compare

@johnjanney johnjanney released this 17 Aug 21:54
a46dff5

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 to advanced 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 WooCommerce latest all 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. Run 32071013547 installed
    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 least stays at 9.9 and the
    floor lane still runs, so nothing about the supported range narrows.

BOGO Select for WooCommerce 2.3.9

Choose a tag to compare

@johnjanney johnjanney released this 17 Aug 21:17
f18c8d7

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.sh reintroduces 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.sh runs 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.sh resolves 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 concluded success. It is now step
    2 of BRIEF.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.yml runs 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.md restates 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 in PACKAGE_EXCLUDES beside
    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, and bin/verify-zip-check.sh still 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.md L-03, the last of that finding's recommendations). It
    compared .php, .js, and .css and 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 — a node_modules/ or a composer.lock inside 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 -Z1 rather than by walking the
    unpacked tree, because a file installed outside bogo-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.sh copies what it returns and
    verify-zip.sh checks the archive against the same thing. They used to keep
    a list each — one in rsync --exclude flags, one in a find prune, and a
    third in the no-rsync fallback — which is why node_modules could 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.md and LICENSE are reported stale, a removed file missing, a
    smuggled composer.lock and node_modules/ excluded, an unknown file extra,
    and an entry outside bogo-select/ out of tree.

  • The parity gate is now required to object, the way the suites are.
    bin/verify-zip-check.sh builds real archives in a temporary sandbox, breaks
    each one of those six ways, and requires verify-zip.sh to 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...

Read more

BOGO Select for WooCommerce 2.3.8

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 05:17

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-type line
    had never reached the file — the script meant to add it aborted before
    writing — so BogoSettings resolved 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() and variation_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 same to_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

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 05:03

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 for intval(), and intval() 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_products holding a nested array) and from a
    request sending product_id[]=7 instead of product_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 the absint( wp_unslash() ) the coding standard
    recognises.

    Found by attempting PHPStan level 9, which is the level that objects to
    mixed being 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,
    and all() 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 int or a float to
    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.dist rather 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 are mixed and the cast is the correct handling rather than
    the defect. Satisfying the rule means an is_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

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 04:28

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.md M-03). The benchmark below found a broad All
    Products search costing 612 database queries against 2,000 products — about
    three per candidate, because each wc_get_product() found its own way to the
    post row, its meta, and its product_type and product_visibility terms.
    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.md M-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 in CODEX-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.php under a real role
    (CODEX-REVIEW.md L-02, and the regression test M-02 asked for).
    AdminSettingsTest.php calls 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
    own current_time(), so DECISION.md D-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

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 03:55

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 run npm ci has a node_modules/ directory, and build-zip.sh had 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.sh passed it. That script exists to
    catch exactly this (CODEX-REVIEW.md M-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, and node_modules was in neither list — so it
    found the same stray .js files 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.md L-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 from npm ci against a committed
    package-lock.json, so it drives the Playwright that file names by integrity
    hash instead of whatever npm install resolved that morning.

    .github/dependabot.yml is 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/checkout 5.1.0 → 7.0.1, actions/setup-node 5.0.0 → 7.0.0,
    actions/upload-artifact 4.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, and ubuntu-latest
    satisfies that. actions/checkout v7 also refuses to check out a fork's PR
    head under pull_request_target and workflow_run; this workflow triggers on
    push and pull_request only.

    MariaDB was the jump with the most behind it: order.test.mjs places 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.md L-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.mjs renders 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

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 02:32

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.md L-04, the half PHPStan does not cover). PHPCS with the
    WordPress standard runs from composer sniff and 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_ids and $site_id in the
    global scope it runs in, and two integration fixtures stopped shadowing
    WordPress's own $order and $mode.

    Everything excluded is excluded with its reason beside it in
    .phpcs.xml.dist, and there is no baseline. manage_woocommerce is 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

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 02:14

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": false and
    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,
    since null and false are 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() and BOGO_Select_Blocks::error() end the request —
    one sends a JSON error, the other throws — and both were documented as
    returning void, so everything after a call to them looked reachable and the
    guard above it looked pointless. Both are never now, 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() returning WC_Product|false had 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 a WC_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(), whose true
    answer is proof there is a product, since it starts with an instanceof.
    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-true states 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.
    Every array in a docblock now says what it holds and every method declares a
    return type: 34 void declarations 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

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 01:37

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 analyse runs
    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 to esc_attr(), six used plugin constants the analyser
    cannot see because it does not run define(), three crossed float and string
    on a price, and two called get_variation_attributes() on a WC_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 a method_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 with Size: Large beneath 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

Choose a tag to compare

@johnjanney johnjanney released this 02 Aug 00:52

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.md M-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-01junk is no longer read as the first of August. Each
    dash-separated part was converted with intval(), 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 as 2026-8-1 still work.

  • A Shop Manager can save the settings page they can already open
    (CODEX-REVIEW.md M-02). The menu and the page both ask for
    manage_woocommerce, which is the capability WooCommerce gives that role, but
    options.php asks for manage_options unless 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.md L-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.md L-01). The
    README described bogo_select_reward_added as 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.md M-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.md L-02).
    The brief has asked for a clean log under WP_DEBUG since 1.0.0 and nothing
    had ever checked. The integration job now turns WP_DEBUG on 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.md L-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.php asserts
    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.md L-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.