Compare commits

..
Author SHA1 Message Date
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
Kristian Wind d9de751e3f Merge pull request #39 from kristianwind/readme-follows-the-rename
CI / Static checks (push) Successful in 1m0s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m20s
1.5.4: the text follows the rename, and a check keeps it that way
2026-09-20 22:40:39 +02:00
kristianwindandClaude Opus 5 cb58ffe26e 1.5.4: the text follows the rename, and a check keeps it that way
Kristian read the readme and found it still telling people to configure the
plugin under WooCommerce, for a page that moved to the Nolimit menu three
releases ago. Sweeping for it found seven stale places, and three of them are
notices THE PLUGIN SHOWS THE USER — the missing-WooCommerce notice, the
activation error and the incomplete-install warning all still called it
WooCommerce Email Discount Manager.

That is the mimir.guide failure exactly: the code moved, the text stood still,
and nothing failed. It was found by somebody reading, which is the only way it
could be found, and that is the argument for the check rather than for being
more careful next time.

ci/static.sh now asserts two things. The name in readme.txt's title matches the
Plugin Name header, because the header is what WordPress shows and everything
else has to agree with it. And no shipped PHP file contains OLD_PLUGIN_NAME,
which is now a value in ci/plugin.conf rather than a habit.

The changelog is deliberately exempt: an entry describes what was true for that
release, and rewriting it would be a different lie. Only text describing the
CURRENT state is checked.

Both halves proven red — a readme title that disagrees, and the old name put
back into one notice.

Not changed: the page stays under Nolimit rather than gaining a second entry
under WooCommerce. One menu is the family's shape, and a duplicate entry is
clutter rather than a fix for a readme that was simply wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:39:19 +02:00
Kristian Wind 7ece3f1ee4 Merge pull request #38 from kristianwind/menu-1-2-2-and-153
CI / Static checks (push) Successful in 57s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m11s
1.5.3: shared-menu 1.2.2, and run the glyph style instead of reading it
2026-09-20 19:09:11 +02:00
kristianwindandClaude Opus 5 6a789349ec 1.5.3: shared-menu 1.2.2, and run the glyph style instead of reading it
678f1f03 is withdrawn. It hard-coded the icon box at 36x34, and WordPress
declares that parent three times with different sizes — 36x34 open, 35x30
folded, 34x30 under 960px — so a fixed number is correct in one state and 4px
too tall in the two a laptop is usually in. cache-manager measured where the
ink lands rather than where the box is and found it worse than that: the glyph
moved from 2.5px above centre to 2.5px below in the folded states, the same
error mirrored, in a state that had been nearly right. 1.2.2 removes the number
entirely with width/height at 100%.

1.5.2 is already published with 678f1f03, so this tree shipped the withdrawn
version. Checked before anything else, because a later candidate fatalled on
every admin page with "Unknown format specifier": that file was never taken
here, and the published 1.5.2 emits its stylesheet cleanly — verified by
running sprintf over its format string rather than reading it. So the shop is
cosmetically off in folded menus, not broken.

New assertion, and it is the one this round is really about: the style
function is INVOKED and its output captured, rather than the source being
read. The fatal that prompted it survived three sessions reading the file,
a clean php -l, a clean PHPCS, and two browsers agreeing about the CSS —
because every one of those was fed the stylesheet by hand rather than through
the printf that produces it. A literal per-cent must be doubled in a format
string, and nothing that looks at the file can see that.

Proven red with that exact fault rather than an arbitrary one: undoubling the
per-cent gives "ValueError: Unknown format specifier ';'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:07:44 +02:00
Kristian Wind db5c5e33c8 Merge pull request #37 from kristianwind/menu-1-2-1-and-152
CI / Static checks (push) Successful in 56s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m14s
1.5.2: take shared-menu 1.2.1, which centres the glyph
2026-09-20 18:54:11 +02:00
kristianwindandClaude Opus 5 6114473be0 1.5.2: take shared-menu 1.2.1, which centres the glyph
Kristian reported the infinity sign sitting above centre in the admin menu on
the live shop. Security traced it to WordPress's own stylesheet rather than
adjusting it by eye: #adminmenu div.wp-menu-image is 36x34 and its :before
carries padding 7px 0, which is exactly 7 + 20 + 7 for a 20px dashicon at
line-height 1. The 1.2.0 rule set line-height 1.4 and inherited that padding,
so the pseudo-element was 42px tall inside a 34px parent.

1.2.1 reproduces the box, drops the padding and centres with flex — geometry
rather than a nudge, because U+221E sits on the maths axis instead of filling
its em box.

It is the same half-done abstraction as the escaping fix: the filter made the
character configurable and left the geometry tuned for one specific character.
Nothing could fail, because the only value anyone ever passed was the one it
was tuned for.

Verified before copying, including the part that makes the check meaningful:
Security predicted php -w WOULD differ this round, since the executable code
changed — so equal stripped hashes would have meant the wrong file. They
differ, and the diff is the version constant plus the CSS geometry, with
wpmgr_css_escape untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 18:52:51 +02:00
12 changed files with 478 additions and 32 deletions
+17 -5
View File
@@ -1,10 +1,10 @@
=== WooCommerce Email Discount Manager ===
=== Nolimit Discounts ===
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.1
Stable tag: 1.6.2
WC requires at least: 6.0
WC tested up to: 11.1.0
License: GPLv2 or later
@@ -14,7 +14,7 @@ Apply discounts and show/hide shipping methods based on the customer's email add
== Description ==
WooCommerce Email Discount Manager lets you give discounts and filter available
Nolimit Discounts lets you give discounts and filter available
shipping methods based on the email address a customer logs in or checks out with.
= Discount Groups =
@@ -63,7 +63,7 @@ coupons in a dropdown.
1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`.
2. Make sure WooCommerce is installed and active.
3. Activate the plugin through the Plugins screen in WordPress.
4. Go to WooCommerce → Email Discounts to configure groups, assignments and shipping rules.
4. Go to Nolimit → Discounts to configure groups, assignments and shipping rules.
= REST API =
@@ -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; 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
+27 -2
View File
@@ -75,7 +75,21 @@ 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.
#
# 9012fe98 = contract 1.2.0. The glyph is now emitted as CSS numeric escapes
# 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.
# Withdrawn: 678f1f03 = 1.2.1: the glyph is centred with flex inside an explicit
# 36x34 box instead of inheriting WordPress's 7px padding, which made the
# pseudo-element 42px tall inside a 34px parent and pushed the sign above
# centre. Kristian reported it from the live shop.
# Earlier: 9012fe98 = contract 1.2.0. The glyph is now emitted as CSS numeric escapes
# rather than through esc_attr(): HTML entities are not decoded by the CSS
# parser, so esc_attr was protecting by coincidence — and a backslash, which it
# leaves alone, IS a CSS escape and swallowed the closing quote. Changing the
@@ -83,4 +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=9012fe98d867b86c993cee2f8d7b0b9fe5b89817fed4cf45346aa4949e2df307
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
+48
View File
@@ -205,6 +205,54 @@ 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: 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.
echo
echo "→ ci/static.sh must catch a ci/ script that configures the clone"
cd "$work/repo"
cp ci/build-zip.sh "$work/build-zip.orig"
for scope in '' '--local' '--global'; do
cp "$work/build-zip.orig" ci/build-zip.sh
line="git config ${scope:+$scope }user.name \"compatibility check\""
printf '%s\n' "$line" >> ci/build-zip.sh
# The plant must BE there before any verdict is believed. A `printf` that
# silently went somewhere else leaves the control state, and every assertion
# below then passes while proving nothing — a hollow probe wearing the
# costume of the most rigorous thing in the suite.
if [ "$(grep -cF "$line" ci/build-zip.sh)" -lt 1 ]; then
bad "could not stage the git-identity fixture for '${scope:-no flag}'"
continue
fi
id_out=$(./ci/static.sh 2>&1 || true)
if printf '%s' "$id_out" | grep -q 'sets a git identity that outlives it'; then
ok "flagged git config ${scope:-(no flag)} user.name"
else
bad "NOT FLAGGED: git config ${scope:-(no flag)} user.name — the clone keeps the robot's name"
fi
done
# The correct form must NOT fire, or the fix trips the check that exists to
# protect it and the next person deletes one of the two.
cp "$work/build-zip.orig" ci/build-zip.sh
printf '%s\n' 'git -c user.name="compatibility check" -c user.email=x commit -m y' >> ci/build-zip.sh
scoped_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$scoped_out" | grep -q 'sets a git identity that outlives it' \
&& bad "FALSE POSITIVE on 'git -c user.name=… commit' — that is the fix, not the fault" \
|| ok "ignored the scoped 'git -c … commit' form"
cp "$work/build-zip.orig" ci/build-zip.sh
# 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]+ scripts in ci/ leave the clone's git identity alone" \
&& ok "the git-identity check reports how many scripts it read" \
|| bad "the git-identity check printed no count — it cannot tell 11 scripts from 0"
# ------------------------------------------------------------------- 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"
+62 -2
View File
@@ -141,6 +141,37 @@ else
echo " $checked plugin-relative include(s) checked"
fi
readme=
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
ci_step "the plugin is called the same thing everywhere"
# 🔴 The rename moved the code and left the text behind: the readme still told
# people to look under WooCommerce for a page that had moved to the Nolimit
# menu, and three notices the PLUGIN SHOWS THE USER still used the old name.
# Kristian found it by reading the readme, which is the only reason it was
# found at all — nothing fails when a sentence goes stale.
#
# Same failure as a privacy page standing still while the code moves. The name
# in the header is the one WordPress shows, so it is the one everything else
# has to agree with.
header_name=$(ci_plugin_header 'Plugin Name')
echo " header: $header_name"
readme_name=$(sed -n -E 's/^=== (.+) ===$/\1/p' "$readme" 2>/dev/null | head -1)
echo " $readme: $readme_name"
[ "$header_name" = "$readme_name" ] \
|| note_fail "The plugin header says '$header_name' but $readme says '$readme_name'."
# Old names in text a user reads. The changelog is exempt: an entry describes
# what was true for that release, and rewriting it would be a different lie.
stale=$(./ci/dist-files.sh | grep -E '\.php$' \
| xargs grep -nF "$OLD_PLUGIN_NAME" 2>/dev/null || true)
if [ -n "$stale" ]; then
printf '%s\n' "$stale" | sed 's/^/ /'
note_fail "The old plugin name '$OLD_PLUGIN_NAME' is still in shipped code."
else
echo " no shipped file still says '$OLD_PLUGIN_NAME'"
fi
ci_step "one version, stated once"
# WordPress compares the update feed against the header and nothing else, so a
# constant or a readme that disagrees with it is how a release ends up either
@@ -276,6 +307,36 @@ elif [ "$fails" = "$before" ]; then
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
fi
ci_step "no ci/ script 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.
#
# Any flag, not just --local: --global sets the whole machine's identity, which
# is the same fault one scope wider and reaches every repository on the box.
#
# The rule: a script may configure ITSELF, never the working copy it was
# handed. Reported by the Nolimit Security session 2026-09-25.
scripts_scanned=$(git ls-files 'ci/*.sh' | wc -l)
if [ "$scripts_scanned" -lt 1 ]; then
note_fail "The git-identity scan found no ci/*.sh files at all, so it proved nothing."
elif git ls-files -z 'ci/*.sh' \
| xargs -0 grep -nE '^[^#]*git config([[:space:]]+--[a-z-]+)*[[:space:]]+user\.'; then
note_fail "A ci/ script sets a git identity that outlives it. Scope it to the one commit with 'git -c user.name=… commit', which does not write .git/config."
else
echo " $scripts_scanned scripts in ci/ 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
@@ -323,8 +384,7 @@ behind() { # claimed current -> true when claimed is the older of the two
# plugin header, so checking the header for it reports "missing" on a plugin
# that states it correctly. "WC tested up to" is the opposite: WooCommerce
# reads that one from the plugin header and nowhere else.
readme=
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
tested_wp=
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
+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;
}
+53 -3
View File
@@ -69,7 +69,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
define( 'WPMGR_MENU_VERSION', '1.2.0' );
define( 'WPMGR_MENU_VERSION', '1.2.3' );
}
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
@@ -160,6 +160,45 @@ if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
* `font-family` has to be overridden: WordPress sets the icon wrapper to
* the Dashicons font, and Dashicons has no infinity glyph, so inheriting
* it renders nothing at all.
*
* 🔴 The box is WordPress's own, measured rather than guessed, and the
* glyph is centred in it geometrically rather than nudged.
*
* wp-admin/css/admin-menu.css sets `div.wp-menu-image:before` to
* `padding: 7px 0` — exactly 7 + 20 + 7 for a 20px dashicon on
* `line-height: 1` in a 34px box. The first version of this rule set
* `line-height: 1.4` and inherited that padding, making the line box 28px
* and the whole pseudo-element 42px inside a 34px parent. Kristian
* reported the result: the glyph sitting above centre.
*
* So: drop the padding that only existed to position a 20px line, and
* centre with flex. That matters more for an arbitrary glyph than for a
* dashicon — U+221E sits on the maths axis rather than filling its em box,
* so any rule that positions by line-height and padding is positioning the
* box and hoping about the glyph. `align-items: center` centres what is
* actually drawn, which is also what keeps working if anyone returns
* something else from `wpmgr_menu_glyph`.
*
* 🔴 `100%%`, not `100%`. This is a printf format string, so a literal
* per-cent sign has to be doubled — and the first version of the
* percentage fix did not, which is a **fatal on every admin page**:
* "Uncaught ValueError: Unknown format specifier". Three sessions agreed
* on that file after reading it, linting it and running PHPCS over it, and
* all three are blind to this: the syntax is valid and the standard has
* nothing to say. Only running the function finds it, and it was the site
* test that did. The fixed-pixel version could not have had this fault,
* so it arrived with the improvement.
*
* 🔴 The size is `100%`, not the 36x34 the desktop menu happens to be.
* That stylesheet declares the parent THREE times and they disagree:
* `#adminmenu div.wp-menu-image` is 36x34, `.folded #adminmenu
* div.wp-menu-image` is 35x30, and under `@media (max-width: 960px)`
* `.auto-fold #adminmenu div.wp-menu-image` is 34x30. A pseudo-element
* fixed at 36x34 is therefore correct on a wide screen with the menu open
* and 4px too tall in both of the other states — which are the states a
* laptop is in. Percentages resolve against whichever parent is in force,
* so there is no number here to be right about. Measured by reading that
* stylesheet, not by eye.
*/
function wpmgr_menu_glyph_style() {
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
@@ -170,7 +209,7 @@ if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the slug is reduced to [a-z0-9_-] and the glyph to CSS numeric escapes; both are escaped for the context they are printed in, which is CSS rather than HTML.
printf(
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;line-height:1.4}</style>',
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;display:flex;align-items:center;justify-content:center;width:100%%;height:100%%;padding:0;line-height:1}</style>',
preg_replace( '/[^a-z0-9_-]/', '', strtolower( WPMGR_MENU_SLUG ) ),
wpmgr_css_escape( $glyph )
);
@@ -298,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>';
+149 -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() );
@@ -317,6 +388,68 @@ wc_edm_is(
isset( $wc_edm_mine[0]['colour'] ) ? $wc_edm_mine[0]['colour'] : null
);
// 🔴 The style function is INVOKED, not read. A version of this file shipped
// with `width:100%` inside a printf format string, where a literal per-cent
// must be doubled — an uncaught ValueError on every admin page. Three sessions
// read it, php -l was clean, PHPCS was clean, and two browsers agreed about
// the CSS, because all of them were fed the stylesheet by hand rather than
// through the PHP that produces it. Only running it finds this.
if ( function_exists( 'wpmgr_menu_glyph_style' ) ) {
$wc_edm_threw = '';
ob_start();
try {
wpmgr_menu_glyph_style();
} catch ( \Throwable $e ) {
$wc_edm_threw = get_class( $e ) . ': ' . $e->getMessage();
}
$wc_edm_style = ob_get_clean();
wc_edm_ok(
'emitting the menu glyph style throws nothing',
'' === $wc_edm_threw,
$wc_edm_threw
);
wc_edm_ok(
'and what it emits is a style element carrying the glyph',
false !== strpos( $wc_edm_style, '<style>' )
&& false !== strpos( $wc_edm_style, '</style>' )
&& false !== strpos( $wc_edm_style, '\\221E' ),
'emitted: ' . substr( $wc_edm_style, 0, 120 )
);
} else {
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
@@ -668,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 : '?'
);
+6 -6
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.1
* 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.1' );
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__ ) );
@@ -211,7 +211,7 @@ final class WC_Email_Discount_Manager {
}
echo '<div class="notice notice-error"><p>';
echo esc_html__( 'WooCommerce Email Discount Manager could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
echo esc_html__( 'Nolimit Discounts could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
echo '</p><p><code>';
echo implode( '</code>, <code>', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</code></p></div>';
@@ -283,7 +283,7 @@ final class WC_Email_Discount_Manager {
*/
public function woocommerce_missing_notice() {
echo '<div class="notice notice-error"><p>';
esc_html_e( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active.', 'wc-email-discount' );
esc_html_e( 'Nolimit Discounts requires WooCommerce to be installed and active.', 'wc-email-discount' );
echo '</p></div>';
}
@@ -296,7 +296,7 @@ final class WC_Email_Discount_Manager {
if ( ! class_exists( 'WooCommerce' ) && ! in_array( 'woocommerce/woocommerce.php', $active_plugins, true ) ) {
deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) );
wp_die(
esc_html__( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
esc_html__( 'Nolimit Discounts requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
esc_html__( 'Plugin activation error', 'wc-email-discount' ),
array( 'back_link' => true )
);