Five files under docs/, the names the family agreed: README, SETUP,
ARCHITECTURE, CONTRACT, RELEASE. Plus ci/check-docs.php, ported from
woo-security-manager 52dd99d, wired into the static job.
CONTRACT.md is generated from the source rather than written from memory, and
that was the right instruction: my first sweep read only single-quoted hook
names and missed two rows, one of them update_plugins_{$hostname} — the hook
behind the live-shop collision, written with interpolation and therefore
invisible to a pattern built from the concept of the line. Nine hooks
documented: six wc_edm_* filters this plugin offers, three wpmgr_* it both
offers and answers through its copy of the frozen shared-menu.php.
Two hooks the checker structurally cannot see are listed by hand with a note
saying so: wc_edm_prune_audit_log (add_action on a class constant) and
update_plugins_{$hostname} (interpolated). A generated list beats a remembered
one and is still a list of what the generator can see.
The checker needed two fixes before it was safe to run here.
🔴 The published version reads HOOK_PREFIX from ci/plugin.conf for the
documented side, and refuses without it — then tests the source side against a
hardcoded 'nlsec_'. Ported verbatim that is not a green over nothing, which
would be easy to spot. Measured in this tree with CONTRACT.md documenting no
hook at all:
3 hook(s) in the shipped source and not in docs/CONTRACT.md:
wpmgr_dashboard_cards, wpmgr_menu_glyph, wpmgr_menu_title
Red, specific, and blind to all six wc_edm_* hooks — because 'wpmgr_' is the
same string in every tree and 'nlsec_' is the one that is not. Fix those three
and it goes green over six undocumented contract hooks, with a red run behind
you as evidence that it works. A partially blind check that fails is more
convincing than one that passes.
Note which step disarmed the guard. The floor at 20 shipped PHP files refused
to run in this 14-file tree, and that refusal was the only thing standing in
the way; the porting instructions were to add HOOK_PREFIX and adjust the floor.
Doing as told is what removed the protection — two wrongs cancelling into a
pass, in the order CLAUDE.md predicts, because the floor is the error you can
see and the prefix is not.
Both numbers now come from the conf (HOOK_PREFIX, MIN_SHIPPED_PHP_FILES) and
both refuse rather than defaulting. The inherited literal 20 is gone: a second
hardcoded number per plugin is a second thing to re-measure on every port.
Ten selftest assertions, 58 total. Four failure modes, the two refusals, the
counts on the pass, and the row that matters most:
🔴 A hook name in a COMMENT must not read as a hook call. CONTRACT.md documents
a filter by showing the apply_filters() a consumer makes, and the same example
sits in a docblock — so a checker that reads comments reports this plugin
consuming a filter it answers, in the file explaining why it must not. That
assertion is a control asserting a PASS, which is the kind that can quietly
become unable to fail, so it names the specific success line rather than
reading an exit code, and it is proven both ways: with token_get_all's
T_COMMENT branch made inert, exactly one of 58 assertions fails and it is that
one.
docs/TESTING-AND-RELEASES.md is folded into RELEASE.md and deleted rather than
left beside it — two copies of a rule is how the copies start to disagree.
Folding surfaced that it still documented the pre-rename Update URI
(kw/woocommerce-email-discount-manager) and still recommended, as future work,
the split into a releases-only repository that has already happened. Both
corrected in place, with the correction dated.
docs/ is not in ci/dist-files.sh — measured, 0 docs files in the zip.
static=0 selftest=0 site=0; site still 69 assertions on WordPress 7.1.2 with
WooCommerce 11.1.2. ci/ and docs/ ship nothing, so no version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4.4 KiB
Setting it up
Installing
Updates come from the plugin's own release feed, not wordpress.org.
- Download
woocommerce-email-discount-manager.zipfrom the latest release athttps://gitea.nolimit.dk/kw/nolimit-discounts-dist/releases. - Plugins → Add New → Upload Plugin.
- Activate. WooCommerce must already be active — activation stops with an error message if it is not, rather than activating into a broken state.
After the first install, the plugin offers its own updates in Dashboard → Updates like any other. Nothing else needs configuring for that to work.
If the update fails with "could not copy file"
The plugin directory is not writable by the user PHP runs as. This is a file ownership problem on the server, not a fault in the release — WordPress cannot overwrite files it does not own. Check it as the web server's user rather than as root, because root can write everything and the check will pass for the wrong reason:
docker exec <container> sh -c 'find /var/www/html/wp-content/plugins/woocommerce-email-discount-manager -type f | wc -l'
docker exec -u 33 <container> sh -c 'find /var/www/html/wp-content/plugins/woocommerce-email-discount-manager -type f -writable | wc -l'
The two numbers must be equal. 33 is www-data on the Debian-based images;
confirm with ps -o user,args -e | grep apache before trusting it.
The three screens
WooCommerce → Nolimit Discounts (also reachable under the shared Nolimit menu) has three tabs.
Groups
A group is a name, a discount percent, and a list of WooCommerce product categories. The percent applies to products in those categories and nothing else. Categories are ordered by drag and drop; the order is cosmetic.
Emails
One row per email pattern. Each row picks one of Group, Coupon or Custom — they are exclusive, and the form will not let a row carry two.
This is the screen where a decision is actually required: how specific should
the pattern be? A domain pattern (@company.com) gives the discount to every
current and future address at that company, including ones nobody has seen yet.
A fragment pattern (john) matches john@anywhere.com and also
johnson@example.org. If a discount is worth real money, use the full address.
Shipping
Per group, per shipping method, one of show or hide.
The consequence worth understanding before using this tab: a method any group is told to show becomes reserved. Customers who match no group stop seeing it everywhere on the shop, not only inside that group. That is the point — it is how "free delivery for approved addresses" stays unavailable to everybody else — but it means adding one show rule changes what strangers see.
How to tell it is working
Not "no errors". A specific number on a specific screen:
-
WooCommerce → Nolimit Discounts → Emails. The row you added is listed with its type and its value. If the tab is empty after saving, the write failed and nothing else below will work.
-
The Nolimit dashboard card. Nolimit in the admin menu shows a card for this plugin with two counts — groups and email assignments. They should equal what the Groups and Emails tabs show. The card is cached for five minutes, so wait that long before reading it as disagreement.
-
The real test, in a cart. Log in as (or check out with) an address that matches a pattern and put a product from a covered category in the cart. The cart totals show a negative fee line carrying the group's name. No line means no match — check the pattern, not the percent.
-
Shipping, if you set any rules. With rules in place, compare the checkout as a matching customer and in a private window as a stranger. The reserved method must be absent for the stranger. Both should show at least one method; if the stranger sees everything, the rules emptied the list and the never-leave-them-stranded fallback returned all of them.
-
Updates. Dashboard → Updates names this plugin with a version and draws its icon. A broken image there means the release is missing its
assets/— report it rather than pressing update.
Settings that need no decision
There is no settings page beyond the three tabs. Rate limiting on the REST API and the audit log's retention are on by default and adjustable only by filter — see CONTRACT.md. A shop that does not want to write code never touches them.