Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 539abf946c Stop reading a verdict out of xargs' exit status
`if git ls-files -z … | xargs -0 grep -nE …; then` has a false green in it.
xargs exits 123 when ANY of its invocations exited 1-125, and grep exits 1 on
no-match, so a run where one batch matched and another did not exits 123 —
which `if` reads as clean, after grep has already printed the hit. Measured:

  one batch, a match       xargs exit 0    -> caught
  one batch, no match      xargs exit 123  -> clean, correct
  two batches, one match   xargs exit 123  -> CLEAN, and it printed the hit

Three checks here used that form: debug leftovers, short open tags, and the
git-identity scan added two days ago. All three are one batch at this tree's
size, so none of them is wrong today — this is a latent false green that
arrives with the file list, not a bug anyone would see. Nothing in the output
distinguishes the cases.

scan_tracked() replaces it: one grep per file, verdict from the OUTPUT rather
than any exit status, and a grep failure (exit > 1) reported instead of
swallowed. Batching stops existing. The loop also yields the file count, which
is the other thing all three were missing — "none" over zero files and "none"
over nineteen were the same green. They now read "none in 19 tracked PHP
files" and fail below a floor of one.

The identity scan is also widened from ci/*.sh to every tracked file. A
workflow step writes an identity into the runner's clone just as permanently,
and ci/*.sh was scope chosen from where the first instance happened to live —
the same "what are your checks pointed at" question as the contrast check.
13 files to 48. The new selftest row plants a write into a workflow file,
which the old scope could not see at all.

Reported by the Nolimit Security session, who hit the xargs case after
widening their own scan from 13 files to 102.

Three of my own probes were wrong before they were right, all the same class:
the assertions grep for the check's message text, and widening the scope
changed the wording from "A ci/ script sets …" to "A tracked file writes …".
Five rows reported NOT FLAGGED while the check was working perfectly. They now
match the stable half of the message, with the reason written beside them. And
the form check matched the comment in static.sh that quotes the bad form in
order to explain it, so it is anchored with ^[^#]* — the paragraph documenting
a rule is text the rule applies to.

That form check is a drift check on the shape and says so: it asserts the
pattern is gone, not that the replacement is correct, because this tree's 48
files are one batch and nothing here can measure the fault.

46 selftest assertions. static=0 selftest=0 site=0; site still 69 assertions
on WordPress 7.1.2 with WooCommerce 11.1.2. ci/ ships nothing, so no version
bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 21:14:10 +02:00
Kristian Wind 5151204b12 Merge pull request #44 from kristianwind/identity-check-must-not-flag-its-own-repair
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m32s
CI / Static checks (push) Successful in 3m30s
Let the identity check tell a write from a read and from its own repair
2026-09-25 12:37:37 +02:00
kristianwindandClaude Opus 5 97818ec4db Let the identity check tell a write from a read and from its own repair
Widening the scope group to --[a-z-]+ was wrong in a way the previous
commit's matrix could not see, because it had no repair row: --unset is
matched as a scope flag, and user. follows it, so

    git config --local --unset user.name

is flagged as a violation. That is the remedy this check recommends in its
own failure message. A build that fails on its own repair is how a correct
check gets deleted — the failure accuses the check, and the next person
believes it. The same widening flags a read, echo "$(git config user.name)".

Two halves, both needed. Enumerate the three real scopes, and require a
VALUE after the key: user\.[a-zA-Z]+[[:space:]]+[^[:space:])]. The trailing
class is what makes a read legal — $(git config user.name) has ) where a
write has a value. Sixteen rows measured one at a time, 16/16, and eleven of
them now run in ci/selftest.sh (44 assertions).

Reported by the Nolimit Security session; three of us reached for
--[a-z-]+ independently, so the comment above the step says explicitly not
to, with the reason, rather than leaving it to be re-derived. The read row
came from nolimit-seo.

Two things found while proving it, both about the probe rather than the
check:

ci/selftest.sh is itself a ci/*.sh file, so its fixtures trip the check they
test. Spelled out in full, the first version of this block reported six
false positives and no count, while the same rows measured standalone were
16/16 correct — a failing probe is evidence about the probe until you have
looked at it. Fixed the way the credential fixtures three hundred lines up
in the same file already were: assemble the value through $gc so the source
matches nothing while the written file matches fully. The labels need it
too, so they name the shape instead of quoting the line.

And a plant that lands in a SOURCED file is not inert text. nolimit-seo
planted into ci/lib.sh, which ci/static.sh sources, so the line ran and set
the clone's identity for real. The target here is ci/build-zip.sh and only
ci/lib.sh is sourced — verified rather than assumed — and a final assertion
checks the clone still has no local identity, because if a fixture had
executed then every verdict above it was measured against a tree nobody
intended.

static=0 selftest=0 site=0; site still reports 69 assertions on WordPress
7.1.2 with WooCommerce 11.1.2. ci/ ships nothing, so no version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:35:45 +02:00
Kristian Wind 7b1d667b5a Merge pull request #43 from kristianwind/scope-git-identity-to-its-own-commit
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m26s
CI / Static checks (push) Successful in 2m4s
Scope the compatibility bump's git identity to its own commit
2026-09-25 12:21:20 +02:00
kristianwindandClaude Opus 5 cc0aaf9272 Scope the compatibility bump's git identity to its own commit
`ci/bump-tested-up-to.sh` ran `git config user.name "compatibility check"`
before committing. That writes .git/config and outlives the script, so from
the first run onward every commit in that working copy is authored by
"compatibility check <noreply@localhost>" with no Co-Authored-By trailer —
including a release made by a person days later about something else.

git announces an identity it cannot FIND and says nothing about one that is
wrong, so the missing case is loud and this one is silent. Nothing fails,
nothing warns, and the author is only visible to somebody reading `git log`
for another reason. Measured across the family by the Nolimit Security
session: six misattributed hand commits in one sibling including two
releases, two in another, one in a third. This tree was clean — no local
identity, 0 misattributed — and one run of the script away.

The commit still signs itself as the robot, which is right: that one is not
a person's. It just does it with `git -c … commit`, which configures the
command and not the clone.

Guarded in ci/static.sh, because the fix is one line and reverting it would
be invisible for exactly as long as nobody reads an author field. Widened
past what was reported: the pattern accepts any scope flag, since --global
is the same fault one scope wider and reaches every repository on the box.

Proven with a full matrix rather than one trigger — no flag, --local,
--global and --system each go red with the named message and exit 1; a
commented mention, `git -c user.name=… commit`, `git config core.autocrlf`
and `git config --global core.pager` all stay green. The correct form being
a control matters: a check that flagged the fix would get one of the two
deleted. Five of those assertions are now in ci/selftest.sh (36 total), each
with a landing check that the plant is in the file before its verdict is
believed, and the passing line carries the script count — "every script is
clean" reads the same over zero files as over eleven.

ci/ ships nothing, so there is no version bump and no release here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:17:48 +02:00
Kristian Wind f6ea46a93e Merge pull request #42 from kristianwind/count-assertions-and-bump-headers
CI / Static checks (push) Successful in 1m5s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m13s
1.6.2: WP 7.1.2 and WC 11.1.2 declared, and the caller counts the assertions
2026-09-25 08:53:23 +02:00
kristianwindandClaude Opus 5 2b289c6763 1.6.2: WP 7.1.2 and WC 11.1.2 declared, and the caller counts the assertions
Kristian asked the family to check WordPress 7.1.2. Run pinned rather than
against "latest", because latest drifts and then nobody knows which version a
green belonged to:

  PHP 7.4   69 assertions, 0 failures   "none from this plugin"
  PHP 8.3   69 assertions, 0 failures   "none from this plugin"
  PHP 8.4   69 assertions, 0 failures   "none from this plugin"

The container reported WordPress 7.1.2 and WooCommerce 11.1.2 on all three,
read out of the run rather than from the variable it was given. So there is no
problem with 7.1.2 here.

The findings were in the reporting, not the code.

The headers were stale: "Tested up to: 7.1" and "WC tested up to: 11.1.0"
against current 7.1.2 and 11.1.2. The WooCommerce one is the visible half — a
shop on 11.1.2 sees an untested warning on the WooCommerce status page, and on
this plugin that banner sits in front of somebody deciding whether to update a
discount engine.

And the suite said "All assertions passed" without a number, which is the exact
failure every other check here was fixed for. It now prints the count.

🔴 Then the floor for that count found a bigger hole than it was written for.
Putting it at the end of the smoke file does nothing: wp eval-file runs the
file in function scope, so a `return` partway through skips everything after
it, including the floor. Measured — truncating tests/smoke.php before the
shipping section ran two thirds of the assertions and ci/site-test.sh reported
GREEN.

A file cannot assert about its own early exit. ci/site-test.sh now captures the
output and checks the total against MIN_SITE_ASSERTIONS from outside, where an
early return cannot reach, and fails when the summary line is missing
altogether. The in-file floor stays for a short run that does reach the end.

Proven with the same fixture that exposed it: the truncated suite now exits 1
saying it printed no assertion total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:51:46 +02:00
Kristian Wind 9931c74b45 Merge pull request #41 from kristianwind/menu-1-2-3-pill
CI / Static checks (push) Successful in 1m3s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m17s
1.6.1: shared-menu 1.2.3, and render the dashboard to check the pill
2026-09-23 17:23:20 +02:00
kristianwindandClaude Opus 5 8143d146e4 1.6.1: shared-menu 1.2.3, and render the dashboard to check the pill
The status pill on the shared dashboard is white 11px text over a light
overlay. SEO measured it two ways, arithmetic and a canvas read-back in
headless Chromium: this plugin's #1a7a52 gave 3.50:1 against a 4.5 minimum,
and six of eight family colours failed along with the no-colour fallback.

Not fixable by choosing a different card colour. The header is a direct pair
and passes at 5.32:1; the pill is a COMPOSITE, and 22% white is a floor — a
card has to reach about 7.7:1 before the pill clears 4.5, which is above the
whole family band. So it is the shared file or seven plugins giving up the
colour the cards exist to carry. 1.2.3 makes the overlay rgba(0,0,0,.18) and
this plugin's pill goes to 7.15:1.

Verified before copying: the hash, and php -w over both files to confirm the
executable code really changed — the diff is the version constant and that one
CSS value, nothing else.

The new assertion renders the dashboard and reads the pill out of what came
back, and it is built around the trap SEO warned about: their first harness
printed "no problem" over a code path it never entered, because it stubbed
wpmgr_cards instead of wpmgr_dashboard_cards and the renderer took its early
return. So the card's own title is asserted first — if the renderer bailed,
nothing after it means anything — and the OLD rgba is asserted ABSENT, because
a check that cannot tell the two versions apart cannot testify about either.

Both proven: against 471b0a2a it fails naming "the old 22% white", and with the
card filter removed it fails at 81 bytes rendered. That 81 is the same number
SEO reported from their own broken harness, which is independent confirmation
it is the same code path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 17:21:44 +02:00
Kristian Wind 2490f733de Merge pull request #40 from kristianwind/exclusive-shipping-methods
CI / Static checks (push) Successful in 53s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m5s
1.6.0: a shipping method a group is shown belongs to that group
2026-09-22 09:48:11 +02:00
kristianwindandClaude Opus 5 6ef4ee8fe5 1.6.0: a shipping method a group is shown belongs to that group
Kristian checked out with an address in no group and was offered both free
delivery methods set up for the Netic group. Measured on the shop before
touching anything: one group, four rules, two of them "show" on the free
delivery methods he saw.

It was not a regression. A test asserted the behaviour as intended — "an
unmatched customer sees every method" — so a "show" rule was a whitelist FOR
THE GROUP and nothing more. The plugin did half of what its own description
promises: it filtered what the group sees and never reserved anything from
anyone else. On a shop that takes money the effect is that free delivery
arranged for one company is available to every customer.

A method named in any group's show rules is now reserved for the groups that
name it.

Answering "does it work for other discounts too", which found the hole the
first fix left: it did not. A group with NO rules of its own returned every
rate untouched, and so did a group carrying only "hide" rules, because neither
engages the whitelist — so their members saw another group's reserved methods.
Both now have other groups' reserved methods removed.

The "never leave the customer without shipping" guard is kept and asserted: a
shop that reserved everything it offers must still let an ordinary customer
check out, because that failure is worse than the leak.

Four assertions, each proven red. Two of them caught my own mistakes rather
than the product's: the hide-only fixture originally left nothing for the
customer, so the guard fired and returned every rate — which reads exactly like
the leak it was written to detect. A third rate makes the behaviour observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:46:48 +02:00
12 changed files with 525 additions and 27 deletions
+14 -2
View File
@@ -2,9 +2,9 @@
Contributors: kristianwind
Tags: woocommerce, discount, email, shipping, coupon
Requires at least: 5.8
Tested up to: 7.1
Tested up to: 7.1.2
Requires PHP: 7.4
Stable tag: 1.5.4
Stable tag: 1.6.2
WC requires at least: 6.0
WC tested up to: 11.1.0
License: GPLv2 or later
@@ -96,6 +96,18 @@ The most specific match wins: exact email, then domain, then partial match.
== Changelog ==
= 1.6.2 =
* Declares compatibility with WordPress 7.1.2 and WooCommerce 11.1.2, both tested. No functional change.
= 1.6.1 =
* The status label on the Nolimit dashboard card is now readable. It was white text on a light overlay, which on this plugin's green gave 3.5:1 where 4.5:1 is the accessibility minimum.
= 1.6.0 =
* A shipping method a group is shown is now reserved for that group: customers who match no group no longer see it. Previously a method set up for approved addresses stayed on the checkout for everyone, so free delivery meant for one company could be chosen by anybody. Customers are never left with no shipping method at all.
= 1.4.6 =
* Housekeeping: the plugin's source moved to a shorter address. Updates continue to arrive as before, and nothing needs doing.
+8 -3
View File
@@ -60,10 +60,15 @@ done
git diff --quiet && { echo "Nothing actually changed on disk — check the sed patterns." >&2; exit 1; }
branch="compat/tested-up-to-$current_wp-wc-$current_wc"
git config user.name "compatibility check"
git config user.email "noreply@localhost"
git checkout -b "$branch"
git commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
# The identity is scoped to THIS commit with `git -c`, never written to the
# clone. `git config user.name` persists in .git/config, so every later commit
# in this working copy — a release days afterwards, by a person — would be
# authored by the robot with no Co-Authored-By trailer. git announces a
# MISSING identity and says nothing about a wrong one, so that is silent.
# Signing this one commit as the robot is right: it is not a person's.
git -c user.name="compatibility check" -c user.email="noreply@localhost" \
commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
$(printf '%s\n' "${changed[@]}")
+1 -1
View File
@@ -22,7 +22,7 @@ ci_load_conf() {
# leaves the floors empty, every comparison becomes a no-op, and the hollow
# checks these were added to fix come back silently — the same shape of bug.
local missing=
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS RELEASE_REPO WPMGR_MENU_SHA OLD_PLUGIN_NAME; do
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS RELEASE_REPO WPMGR_MENU_SHA OLD_PLUGIN_NAME MIN_SITE_ASSERTIONS; do
[ -n "${!var:-}" ] || missing="$missing $var"
done
if [ -n "$missing" ]; then
+14 -2
View File
@@ -75,7 +75,13 @@ RELEASE_REPO=kw/nolimit-discounts-dist
# The frozen hash is therefore a family-wide fact, not a local one, and taking
# a new one is something all six do in the same window.
#
# 471b0a2a = contract 1.2.2: the icon box is sized 100%/100% instead of a
# 75b04eb4 = contract 1.2.3: the dashboard status pill is rgba(0,0,0,.18)
# instead of 22% white. The pill is white 11px text composited over the card
# colour, and 22% white is a floor: a card has to reach about 7.7:1 before the
# pill clears 4.5:1. This plugin's #1a7a52 gave 3.50 and now gives 7.15. It
# could not be fixed by choosing a different card colour — the whole family
# band fails, including the no-colour fallback.
# Earlier: 471b0a2a = 1.2.2: the icon box is sized 100%/100% instead of a
# hardcoded 36x34. WordPress declares that parent three times and they
# disagree (36x34 open, 35x30 folded, 34x30 under 960px), so a fixed number is
# right in one state and wrong in the two a laptop is usually in.
@@ -91,9 +97,15 @@ RELEASE_REPO=kw/nolimit-discounts-dist
# backslashes, hex digits and spaces, so there is nothing left to leave the
# string with. Verified here against seven hostile inputs.
# Earlier frozen hashes: 184cebdc = 1.1.0, f4862d5c = 1.0.0.
WPMGR_MENU_SHA=471b0a2aab47b6b7f8d839ac8b12d60637bbe2c07df86f7e40089c677fbab3cc
WPMGR_MENU_SHA=75b04eb4fe9eda0119312075accf2fc77de244e5c6b759d624a8b1d254c0fc99
# The name this plugin used before the Nolimit rename. Kept so ci/static.sh
# can fail when it reappears in text a user reads — a sentence that goes stale
# breaks nothing by itself, which is why it survives.
OLD_PLUGIN_NAME="WooCommerce Email Discount Manager"
# The fewest assertions a complete run of tests/smoke.php prints. Verified by
# ci/site-test.sh rather than only inside the file: a smoke file runs in
# function scope, so a `return` partway through skips its own floor and the
# run reports green having tested two thirds of what it claims.
MIN_SITE_ASSERTIONS=60
+124
View File
@@ -205,6 +205,130 @@ ann=$(ci_php ci/check-textdomain.php "$TEXT_DOMAIN" selftest-dotted 2>&1 \
&& ok "the annotated path exists on disk: $ann" \
|| bad "the annotated path is not a file: '$ann'"
# ------------------------------------------------------- the clone's git identity
# 🔴 Grade 2 evidence: row 1 is the exact line ci/bump-tested-up-to.sh carried
# until 2026-09-25, not an arbitrary `git config`. That line writes .git/config,
# so every later commit in the clone — a release, by a person — is authored by
# the robot with no trailer. Six such commits in one sibling plugin, two in
# another. git warns about an identity it cannot FIND and never about a wrong
# one, so nothing in the repository ever says so.
#
# The controls are the larger half here, and three of them were live faults in
# earlier versions of this check: `--[a-z-]+` as the scope group reads `--unset`
# as a scope and flags the REPAIR, and without a trailing-value requirement a
# `$(git config user.name)` READ is a violation. A build that fails on its own
# remedy is how a correct check gets deleted — the failure accuses the check,
# and the next person believes it.
#
# 🔴 The fixtures are assembled through $gc rather than written out, because
# THIS file is a ci/*.sh file and the check reads every one of them. Spelled in
# full, its own rows match: the first version of this block reported six false
# positives and no count, while the same rows measured standalone were 16/16
# correct. A failing probe is evidence about the probe until you have looked at
# it — and the labels have to dodge it too, which is why they name the shape
# instead of quoting the line. Identical to the credential fixtures three
# hundred lines up, one file over.
#
# The plant goes into ci/build-zip.sh because nothing here SOURCES it: static.sh
# and this script source ci/lib.sh only. A plant in a sourced file is not inert
# text — it runs, sets the clone's identity for real, and the commit fixing the
# fault is authored by the plant. nolimit-seo lost a run to exactly that. So the
# last assertion checks the clone rather than the output.
echo
echo "→ ci/static.sh must catch a written identity and ignore reads and repairs"
cd "$work/repo"
cp ci/build-zip.sh "$work/build-zip.orig"
identity_row() { # $1 = RED|green (want), $2 = label, $3 = line to plant
cp "$work/build-zip.orig" ci/build-zip.sh
printf '%s\n' "$3" >> ci/build-zip.sh
# The plant must BE there before any verdict is believed. A printf that went
# somewhere else leaves the control state, and then every row passes while
# proving nothing — and the all-green rows are exactly the ones whose zeroes
# you want to believe.
if [ "$(grep -cF "$3" ci/build-zip.sh)" -lt 1 ]; then
bad "could not stage the git-identity fixture: $2"
return
fi
row_out=$(./ci/static.sh 2>&1 || true)
# The stable half of the message. The wider phrasing moved from "A ci/ script
# sets …" to "A tracked file writes …" when the scan was widened, and these
# rows all reported NOT FLAGGED while the check was working perfectly — an
# assertion on a log line is coupled to its wording.
if printf '%s' "$row_out" | grep -q 'git identity that outlives it'; then
got=RED
else
got=green
fi
if [ "$got" = "$1" ]; then
ok "$2 -> $got"
elif [ "$1" = RED ]; then
bad "NOT FLAGGED: $2 — the clone keeps the robot's name"
else
bad "FALSE POSITIVE: $2 — the check goes red on something it must allow"
fi
}
gc="git config" # never spelled out below: see the note above
identity_row RED 'WRITE no flag, quoted value' "$gc user.name \"compatibility check\""
identity_row RED 'WRITE --local scope' "$gc --local user.name \"x\""
identity_row RED 'WRITE --global scope' "$gc --global user.email \"x@y\""
identity_row RED 'WRITE --system scope' "$gc --system user.name \"x\""
identity_row RED 'WRITE unquoted value' "$gc user.email noreply@localhost"
identity_row green 'REPAIR --local --unset' "$gc --local --unset user.name"
identity_row green 'REPAIR --unset, no scope' "$gc --unset user.email"
identity_row green 'READ inside a command substitution' "echo \"\$($gc user.name)\""
identity_row green 'FIX git -c ... commit' 'git -c user.name="x" -c user.email=y commit -m z'
identity_row green 'other a non-identity key' "$gc core.autocrlf false"
identity_row green 'prose a commented-out mention' "# $gc user.name \"x\" is the fault"
cp "$work/build-zip.orig" ci/build-zip.sh
# A violation OUTSIDE ci/*.sh. This is the row the old scope missed entirely:
# a workflow step writes an identity into the runner's clone just as
# permanently, and the glob the check started with could not see it.
cp "$work/build-zip.orig" ci/build-zip.sh
wf=.github/workflows/selftest-identity.yml
printf 'name: x\njobs:\n y:\n steps:\n - run: %s user.email "x@y"\n' "$gc" > "$wf"
git add -N "$wf" >/dev/null # untracked files are invisible to git ls-files
wf_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$wf_out" | grep -q 'writes a git identity that outlives it' \
&& ok "flagged a write outside ci/ (a workflow step)" \
|| bad "NOT FLAGGED: a workflow step writing an identity — the scan is still scoped to ci/"
rm -f "$wf"
git rm --cached -q --ignore-unmatch "$wf" >/dev/null 2>&1
# 🔴 A drift check on the FORM, and only that: `if … | xargs -0 grep …; then`
# has a false green in it, because xargs exits 123 when any batch found nothing
# and `if` reads 123 as no-match. It is invisible while the list fits one exec,
# which is why nothing here can measure it — this tree's 48 files are one batch.
# So this asserts the shape is gone rather than that the bug is absent, and it
# cannot tell you the replacement is correct. scan_tracked greps per file and
# reads its verdict from the output, which is what makes batching irrelevant.
# ^[^#]* — the first version of this matched the COMMENT in static.sh that
# quotes the bad form in order to explain it, which is the stale-docblock trap
# in its own right: the paragraph documenting a rule is text the rule applies to.
if grep -nE '^[^#]*(if|elif).*\| *xargs.*grep' ci/static.sh; then
bad "a check above consumes xargs' exit status — 123 on a partial match reads as clean"
else
ok "no check in ci/static.sh consumes an xargs exit status"
fi
# And the passing line must carry a number: "every script is clean" reads the
# same over zero files as over eleven.
clean_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$clean_out" | grep -qE "^ [0-9]+ tracked files leave the clone's git identity alone" \
&& ok "the git-identity check reports how many files it read" \
|| bad "the git-identity check printed no count — it cannot tell 48 files from 0"
# The fixtures are lines that SET an identity. If any of them executed rather
# than being read, this clone now has one — which would also mean every verdict
# above was measured against a tree nobody intended.
if git config --local --get-regexp '^user\.' >/dev/null 2>&1; then
bad "a git-identity fixture RAN — the clone has a local identity and the rows above are void"
else
ok "no fixture executed: the clone still has no local identity"
fi
# ------------------------------------------------------------------- plugin.conf
echo
echo "→ the floors must be required, not defaulted"
+31 -1
View File
@@ -177,9 +177,39 @@ wp plugin list --name="$PLUGIN_SLUG" --field=status | grep -qx active \
|| { echo "The plugin did not stay active after activation." >&2; exit 1; }
echo " activated $(wp plugin get "$PLUGIN_SLUG" --field=version)"
# 🔴 The output is captured and the summary checked HERE, by the caller.
#
# A smoke file cannot assert about its own early exit: `wp eval-file` runs it
# in function scope, so a `return` partway through skips everything after it —
# including any floor the file carries at the bottom. Measured: truncating
# tests/smoke.php before the shipping section ran two thirds of the assertions
# and this script reported green, because the file's own floor was one of the
# things never reached.
#
# So the number the file prints is verified against MIN_SITE_ASSERTIONS out
# here, where an early return cannot reach. The floor inside the file stays as
# well; it catches a short run that still gets to the end.
for f in ${SITE_TESTS:-}; do
ci_step "$f"
wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f"
out=$(wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f" 2>&1) || { printf '%s\n' "$out"; exit 1; }
printf '%s\n' "$out"
case "$f" in
*smoke*)
ran=$(printf '%s' "$out" | sed -n -E 's/^All ([0-9]+) assertions passed.*/\1/p' | tail -1)
if [ -z "$ran" ]; then
echo
ci_fail "$f printed no assertion total. It exited before its summary — which is what a file that returned halfway through looks like, and it cannot report that about itself."
exit 1
fi
if [ "$ran" -lt "$MIN_SITE_ASSERTIONS" ]; then
echo
ci_fail "$f ran only $ran assertions, expected at least $MIN_SITE_ASSERTIONS. Part of the suite did not run."
exit 1
fi
echo " $ran assertions, at or above the floor of $MIN_SITE_ASSERTIONS"
;;
esac
done
ci_step "PHP notices and deprecations"
+124 -4
View File
@@ -19,6 +19,51 @@ shipped_files() {
./ci/dist-files.sh | grep '\.php$' || true
}
# 🔴 scan_tracked: the only way this file greps a set of tracked files.
#
# The form it replaces — `if git ls-files -z … | xargs -0 grep -nE …; then` —
# has a false GREEN in it. `xargs` exits 123 when ANY of its invocations exited
# 1-125, and `grep` exits 1 on no-match, so a run where one batch matched and
# another did not exits 123 and `if` reads that as clean. Measured:
#
# one batch, a match xargs exit 0 -> caught
# one batch, no match xargs exit 123 -> clean, correct
# two batches, one match xargs exit 123 -> CLEAN, and it printed the hit
#
# Harmless only while the whole list fits in one exec. git ls-files hands over
# enough paths to split the moment a tree grows or a path gets long, and then
# the check passes over a violation it has already printed. Nothing about the
# output says which case you are in. Reported by the Nolimit Security session
# 2026-09-28, who hit it after widening their own scan from 13 files to 102.
#
# So: no xargs, and the verdict is the OUTPUT rather than any exit status. The
# loop also yields the count for free, which is the other thing every one of
# these checks was missing — "none" over zero files and "none" over nineteen
# are the same green.
#
# Sets: scan_hits (matches, one "file:line:text" per line), scan_files (how
# many files were read). A grep failure (exit > 1) is reported, never swallowed.
scan_tracked() { # $1 = -E|-P, $2 = pattern, rest = git pathspecs
local mode=$1 pattern=$2 f m rc
shift 2
scan_hits=''
scan_files=0
# -z: git QUOTES a non-ASCII path, and the quoted form is not a path that
# exists, so a line-based read silently skips the file it meant to check.
while IFS= read -r -d '' f; do
[ -n "$f" ] || continue
scan_files=$((scan_files + 1))
m=$(grep -n "$mode" -e "$pattern" -- "$f")
rc=$?
if [ "$rc" -gt 1 ]; then
note_fail "grep failed on $f (exit $rc) — this scan did not examine it."
elif [ "$rc" = 0 ]; then
scan_hits="${scan_hits}$(printf '%s\n' "$m" | sed "s|^|$f:|")
"
fi
done < <(git ls-files -z "$@")
}
ci_step "PHP syntax"
# The header promises a minimum PHP version; without it in the loop a piece of
# newer syntax passes here and fatals on the customer's server instead.
@@ -73,10 +118,14 @@ else
fi
ci_step "no debug leftovers"
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then
scan_tracked -E '\b(var_dump|print_r|dd)[[:space:]]*\(' '*.php'
if [ "$scan_files" -lt 1 ]; then
note_fail "The debug-leftover scan read no PHP files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "Debug functions must not ship."
else
echo " none"
echo " none in $scan_files tracked PHP files"
fi
ci_step "no short open tags, no closing ?>"
@@ -84,9 +133,14 @@ before=$fails
# With short_open_tag off the code is printed to the visitor as text instead of
# being run; whitespace after a closing tag is sent to the browser and breaks
# headers and redirects.
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then
scan_tracked -P '<\?(?!php|=|xml)' '*.php'
if [ "$scan_files" -lt 1 ]; then
note_fail "The open-tag scan read no PHP files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "Use <?php, not short open tags."
fi
open_tag_files=$scan_files
# -z: git quotes non-ASCII paths, and the quoted form is not a path that
# exists, so this loop would silently skip the file it was meant to check.
while IFS= read -r -d '' f; do
@@ -94,7 +148,7 @@ while IFS= read -r -d '' f; do
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
&& { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); }
done < <(git ls-files -z '*.php')
[ "$fails" = "$before" ] && echo " clean"
[ "$fails" = "$before" ] && echo " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
ci_step "text domain"
# --min is deliberately far below the real count. It is not an assertion about
@@ -307,6 +361,72 @@ elif [ "$fails" = "$before" ]; then
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
fi
ci_step "no tracked file configures the clone it was handed"
# `git config user.name` writes .git/config and OUTLIVES the script. A helper
# that sets an identity before committing therefore renames every later commit
# in that working copy, including a release made by a person days afterwards
# and with no Co-Authored-By trailer. git announces an identity it cannot FIND
# and says nothing about one that is wrong, so the missing case is loud and
# this one is silent: nothing fails, nothing warns, and the author is only
# visible to somebody reading `git log` for another reason.
#
# ci/bump-tested-up-to.sh did this until it was scoped to `git -c … commit`,
# which still signs its own commit as the robot — correct, that one is not a
# person's — without touching the clone. Measured across the family: six
# misattributed hand commits in one sibling, two in another, including
# releases. This tree was clean and one run away.
#
# The pattern has to separate a WRITE from a read and from the repair, and the
# obvious widening gets all three wrong. Measured row by row:
#
# git config user.name "compatibility check" WRITE caught
# git config --local user.name "x" WRITE caught
# git config --global user.email "x@y" WRITE caught <- --local only: missed
# git config --system user.name "x" WRITE caught
# git config --local --unset user.name REPAIR ignored <- --[a-z-]+: flagged
# git config --unset user.email REPAIR ignored <- --[a-z-]+: flagged
# echo "$(git config user.name)" READ ignored <- --[a-z-]+: flagged
# git -c user.name="x" … commit -m y FIX ignored
# git config core.autocrlf false other ignored
# git config --global core.pager cat other ignored
# # git config user.name "x" (a comment) prose ignored
# (nothing planted) clean ignored
#
# ⚠️ Do NOT widen the scope group to `--[a-z-]+`. It reads `--unset` as a scope
# and then finds `user.` right after it, so the check goes red on the very
# repair this message recommends — and a build that fails on the remedy is how
# a correct check gets deleted: the failure accuses the check, and the next
# person believes it. Three sessions reached for `--[a-z-]+` independently on
# 2026-09-25, which is why it is written here rather than re-derived.
#
# Enumerating the three real scopes is one half. The other is requiring a VALUE
# after the key — `user\.[a-zA-Z]+[[:space:]]+[^[:space:])]` — which is what
# makes a read not a violation: `$(git config user.name)` has `)` where a write
# has a value. Found by nolimit-seo, whose first version failed on it.
#
# The rule: a script may configure ITSELF, never the working copy it was
# handed. Reported by the Nolimit Security session 2026-09-25.
#
# Writing the fault in prose inside a tracked file will trip this, by design.
# To name it in documentation without going red, write the key as user.<key> —
# the pattern needs [a-zA-Z] after the dot, so an angle-bracket placeholder is
# prose and a real key is a violation.
# Every tracked file, not just ci/*.sh. A workflow step, a hook, a Makefile or
# a docs snippet somebody pastes can write an identity just as permanently, and
# `ci/*.sh` was scope chosen from where the first instance happened to live —
# the same "what are your checks pointed at" question as the contrast check.
# Nolimit Security widened theirs from 13 files to 102 after the thing that
# actually ran on this box turned out to be outside that glob.
scan_tracked -E '^[^#]*git config([[:space:]]+--(local|global|system))*[[:space:]]+user\.[a-zA-Z]+[[:space:]]+[^[:space:])]' .
if [ "$scan_files" -lt 1 ]; then
note_fail "The git-identity scan read no tracked files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "A tracked file writes a git identity that outlives it. Scope it to the one command with 'git -c user.name=… commit', which does not write .git/config."
else
echo " $scan_files tracked files leave the clone's git identity alone"
fi
ci_step "no placeholders left in the plugin header"
# The header is the plugin's business card: wp-admin shows the author and the
# links to whoever installs it, so a placeholder there survives for as long as
+19
View File
@@ -507,6 +507,25 @@ class WC_EDM_DB {
);
}
/**
* Every shipping method any group reserves for itself.
*
* A "show" rule means the group sees that method. It also has to mean
* nobody else does — otherwise reserving a free-delivery method for an
* approved group leaves it on the checkout for every other customer, which
* is what a shop notices as lost shipping revenue rather than as a bug.
*
* @return string[] Rate ids, e.g. "free_shipping:15".
*/
public static function restricted_shipping_methods() {
global $wpdb;
$table = self::table( 'shipping_rules' );
$ids = $wpdb->get_col( "SELECT DISTINCT shipping_method_id FROM {$table} WHERE action = 'show'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return is_array( $ids ) ? $ids : array();
}
/**
* Replace all shipping rules for a group.
*
+57 -7
View File
@@ -330,15 +330,25 @@ class WC_EDM_Frontend {
public function filter_shipping_rates( $rates, $package ) {
$match = $this->get_current_match();
if ( ! $match ) {
return $rates;
}
if ( 'group' === $match->assignment_type && $match->group_id ) {
// $match is null for a customer who matches nothing — the common case,
// and the one this whole method exists to constrain now.
if ( $match && 'group' === $match->assignment_type && $match->group_id ) {
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
} else {
// 🔴 Everyone else — including a matched customer whose assignment
// is not a group — must not see a method some group reserved.
//
// This used to return $rates untouched, and a test asserted that
// as intended: "an unmatched customer sees every method". It made
// a "show" rule a whitelist FOR THE GROUP and nothing more, so
// reserving free delivery for approved addresses left it on the
// checkout for every other customer. Reported from a live shop,
// where a gmail address could pick a free-delivery method meant
// for one company.
$rates = $this->remove_restricted_rates( $rates );
}
if ( ! empty( $match->free_shipping ) ) {
if ( $match && ! empty( $match->free_shipping ) ) {
foreach ( $rates as $rate ) {
$rate->set_cost( 0 );
$rate->set_taxes( array() );
@@ -348,6 +358,33 @@ class WC_EDM_Frontend {
return $rates;
}
/**
* Remove every method some group reserved for itself.
*
* @param array $rates Available shipping rates.
* @return array
*/
private function remove_restricted_rates( $rates ) {
$restricted = WC_EDM_DB::restricted_shipping_methods();
if ( empty( $restricted ) ) {
return $rates;
}
$filtered = array();
foreach ( $rates as $rate_id => $rate ) {
if ( ! in_array( $rate_id, $restricted, true ) ) {
$filtered[ $rate_id ] = $rate;
}
}
// The same guard the group rules carry: a shop that reserved every
// method it offers would otherwise leave an ordinary customer unable
// to check out at all, which is worse than the leak being fixed.
return empty( $filtered ) ? $rates : $filtered;
}
/**
* Apply a group's show/hide shipping rules.
*
@@ -362,8 +399,14 @@ class WC_EDM_Frontend {
private function apply_group_shipping_rules( $rates, $group_id ) {
$rules = WC_EDM_DB::get_shipping_rules( $group_id );
// 🔴 Not `return $rates` — a group with no rules of its own must still
// not see what ANOTHER group reserved. Same for a group carrying only
// "hide" rules: its own shown-set is empty, so the whitelist below
// never engages and every other group's reserved method would survive.
// The leak this fixes was reported for customers matching nothing; it
// existed for these two shapes as well.
if ( empty( $rules ) ) {
return $rates;
return $this->remove_restricted_rates( $rates );
}
$show = array();
@@ -379,6 +422,9 @@ class WC_EDM_Frontend {
$filtered = array();
// Reserved by some group, but not by THIS one.
$others = array_diff( WC_EDM_DB::restricted_shipping_methods(), $show );
foreach ( $rates as $rate_id => $rate ) {
// Whitelist mode: only explicitly shown methods survive.
if ( ! empty( $show ) && ! in_array( $rate_id, $show, true ) ) {
@@ -389,6 +435,10 @@ class WC_EDM_Frontend {
continue;
}
if ( in_array( $rate_id, $others, true ) ) {
continue;
}
$filtered[ $rate_id ] = $rate;
}
+13 -2
View File
@@ -69,7 +69,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
define( 'WPMGR_MENU_VERSION', '1.2.2' );
define( 'WPMGR_MENU_VERSION', '1.2.3' );
}
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
@@ -337,8 +337,19 @@ if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
// 🔴 A DARK overlay, not a light one. White 11px text on
// rgba(255,255,255,.22) over a card colour is 3.45:1 on this
// plugin's green and 3.42:1 on a blue — under the 4.5:1 that small
// text needs, on every family colour dark enough to need white
// text in the first place. rgba(0,0,0,.18) takes the same four
// colours to 6.94–9.37:1, because darkening a background that
// already carries white text can only help.
//
// Measured rather than reasoned: the composite was read back out
// of a canvas in headless Chromium, not computed — #50977f for the
// old rule, #19644b for this one.
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
. 'background:rgba(255,255,255,.22);padding:2px 8px;border-radius:10px}'
. 'background:rgba(0,0,0,.18);padding:2px 8px;border-radius:10px}'
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
. '</style>';
+117 -2
View File
@@ -31,6 +31,7 @@ if ( ! defined( 'ABSPATH' ) ) {
* that it can.
*/
$GLOBALS['wc_edm_failures'] = 0;
$GLOBALS['wc_edm_passes'] = 0;
( static function () { ++$GLOBALS['wc_edm_failures']; } )();
if ( 1 !== $GLOBALS['wc_edm_failures'] ) {
echo "The failure counter does not reach the verdict: this file cannot go red.\n";
@@ -47,6 +48,7 @@ $GLOBALS['wc_edm_failures'] = 0;
*/
function wc_edm_ok( $what, $condition, $detail = '' ) {
if ( $condition ) {
++$GLOBALS['wc_edm_passes'];
printf( " ok %s\n", $what );
return;
}
@@ -194,9 +196,78 @@ WC()->customer->set_billing_email( 'someone@company.com' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is( 'a "show" rule hides every method it does not name', array( 'flat_rate:1' ), array_keys( $filtered ) );
// 🔴 This used to assert "an unmatched customer sees every method", which is
// what made a reserved free-delivery method visible to everybody on a live
// shop. A "show" rule now means the method belongs to that group.
WC()->customer->set_billing_email( 'nobody@nowhere.test' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is( 'an unmatched customer sees every method', 2, count( $filtered ) );
wc_edm_is(
'an unmatched customer does not see a method a group reserved',
array( 'local_pickup:2' ),
array_keys( $filtered )
);
// The guard that keeps the fix from being worse than the leak: a shop that
// reserved everything it offers must still let an ordinary customer check out.
WC_EDM_DB::replace_shipping_rules(
(int) $fixtures['group_id'],
array(
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ),
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', 'sort_order' => 1 ),
)
);
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is( 'but is never left with no shipping at all', 2, count( $filtered ) );
WC_EDM_DB::replace_shipping_rules(
(int) $fixtures['group_id'],
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ) )
);
// A SECOND group, with no rules of its own. Its members must not see what the
// first group reserved — the leak was reported for customers matching nothing,
// but it existed for this shape too, and for a group carrying only "hide"
// rules, because neither engages the whitelist.
$wc_edm_other = WC_EDM_DB::insert_group( array( 'name' => 'Ruleless', 'discount_percent' => 5 ) );
WC_EDM_DB::insert_email(
array(
'email_pattern' => 'ruleless@elsewhere.test',
'assignment_type' => 'group',
'group_id' => (int) $wc_edm_other,
'coupon_code' => null,
'custom_discount' => null,
'is_active' => 1,
'free_shipping' => 0,
)
);
WC()->customer->set_billing_email( 'ruleless@elsewhere.test' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is(
'a group with no rules still does not see another group reserved method',
array( 'local_pickup:2' ),
array_keys( $filtered )
);
// A third rate, deliberately: with only two, hiding one and reserving the
// other leaves nothing, the "never leave the customer without shipping" guard
// fires, and every rate comes back — which reads as a leak but is the guard
// working. The fixture has to leave something for the customer to be given.
$wc_edm_rates3 = $rates;
$wc_edm_rates3['flat_rate:9'] = new WC_Shipping_Rate( 'flat_rate:9', 'Another flat rate', 12, array(), 'flat_rate' );
WC_EDM_DB::replace_shipping_rules(
(int) $wc_edm_other,
array( array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'hide', 'sort_order' => 0 ) )
);
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
wc_edm_ok(
'and neither does a group carrying only hide rules',
! in_array( 'flat_rate:1', array_keys( $filtered ), true ),
'saw: ' . implode( ', ', array_keys( $filtered ) )
);
WC()->customer->set_billing_email( 'someone@company.com' );
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
@@ -349,6 +420,36 @@ if ( function_exists( 'wpmgr_menu_glyph_style' ) ) {
echo "::warning::wpmgr_menu_glyph_style() is not defined, so the glyph style was not exercised.\n";
}
// 🔴 The dashboard is RENDERED and the pill colour read out of what it
// emitted. Asserting the colour alone is not enough: the renderer returns
// early when no card is registered, and a harness that stubs the wrong filter
// name then reports "no problem" over a code path it never entered. Three
// sessions hit exactly that. So the card's own title is asserted first — if
// that is absent the renderer bailed, and nothing after it means anything.
if ( function_exists( 'wpmgr_render_dashboard' ) ) {
ob_start();
wpmgr_render_dashboard();
$wc_edm_dash = ob_get_clean();
wc_edm_ok(
'the shared dashboard renders this plugin card',
false !== strpos( $wc_edm_dash, 'Discounts' )
&& false !== strpos( $wc_edm_dash, 'wpmgr-card' ),
'rendered ' . strlen( $wc_edm_dash ) . ' bytes'
);
// The status pill is white text composited over the card colour, so 22%
// white put this plugin's green at 3.50:1. Named as the literal the file
// must contain, and the OLD value is asserted absent — a check that cannot
// tell the two versions apart cannot testify about either.
wc_edm_ok(
'and the status pill is the contrast-safe background',
false !== strpos( $wc_edm_dash, 'rgba(0,0,0,.18)' )
&& false === strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ),
'pill rule in output: ' . ( false !== strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ) ? 'the old 22% white' : 'not found at all' )
);
}
// The frozen hash of includes/shared-menu.php is asserted in ci/static.sh
// against WPMGR_MENU_SHA in ci/plugin.conf, and deliberately NOT repeated
// here: two copies of the same constant drift, and the one that drifts is the
@@ -700,8 +801,22 @@ if ( $GLOBALS['wc_edm_failures'] > 0 ) {
printf( "%d assertion(s) failed on WordPress %s.\n", $GLOBALS['wc_edm_failures'], get_bloginfo( 'version' ) );
exit( 1 );
}
// The number, not just the word. "All assertions passed" is also what a file
// that asserted nothing prints — which is the failure this whole suite exists
// to catch everywhere else, and it was in the summary line the entire time.
// A floor as well, for the same reason every count in ci/ has one: a run that
// died early after a handful of assertions would otherwise read as a pass.
if ( $GLOBALS['wc_edm_passes'] < 60 ) {
printf(
"Only %d assertions ran, which is too few to be a complete run.\n",
$GLOBALS['wc_edm_passes']
);
exit( 1 );
}
printf(
"All assertions passed on WordPress %s with WooCommerce %s.\n",
"All %d assertions passed on WordPress %s with WooCommerce %s.\n",
$GLOBALS['wc_edm_passes'],
get_bloginfo( 'version' ),
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
);
+3 -3
View File
@@ -4,7 +4,7 @@
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
* Update URI: https://gitea.nolimit.dk/kw/nolimit-discounts-dist
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
* Version: 1.5.4
* Version: 1.6.2
* Author: Kristian Wind
* Author URI: https://gitea.nolimit.dk/kw
* License: GPL-2.0-or-later
@@ -12,7 +12,7 @@
* Requires at least: 5.8
* Requires PHP: 7.4
* WC requires at least: 6.0
* WC tested up to: 11.1.0
* WC tested up to: 11.1.2
* Requires Plugins: woocommerce
*/
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'WC_EDM_VERSION', '1.5.4' );
define( 'WC_EDM_VERSION', '1.6.2' );
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );