Compare commits

...
Author SHA1 Message Date
kw 4a93a5fde8 Merge pull request '1.7.0: shipping rule "Only" reserves a method, "Show" no longer does' (#2) from shipping-only into main
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m14s
Release / release (push) Failing after 1m32s
CI / Static checks (push) Successful in 4m58s
2026-10-02 11:24:14 +00:00
kristianwind 1752bcf8dd Merge branch 'recopy-check-docs' into shipping-only
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m39s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / Static checks (pull_request) Successful in 5m27s
2026-10-02 12:41:10 +02:00
kristianwindandClaude Code c3fdec4e04 Docs: avoid naming the release in prose; site-test count is 73
CI / Static checks (pull_request) Failing after 31s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m20s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 12:36:01 +02:00
kristianwindandClaude Code 0793a1ee82 1.7.0: shipping rule "Only" reserves a method, "Show" no longer does
Since 1.6.0 "Show" both whitelisted a method for its group and hid it from
everybody else. A group shown ordinary methods next to a free one therefore
took the ordinary ones away from every stranger, who was left with only the
free method. "Only" now carries the reservation; "Show" is just the group's
whitelist. Existing show rules are converted to only once on update so the
checkout does not change until the shop chooses.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 12:28:45 +02:00
kristianwindandClaude Code ce160692a7 Re-sync check-docs.php with the family, and let it run where git does not
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m18s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / Static checks (pull_request) Successful in 4m38s
Takes woo-security-manager's current checker (sibling-hook boundary via FAMILY_PREFIXES, table-row scoping), keeping the floor in ci/plugin.conf. dist-files.sh now lists the same files in a git-less copy, which is what ci_php's php container is handed on the Gitea runner; before, check-docs failed there with exit 127.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 08:46:20 +02:00
Kristian Wind 5875638f2c Merge pull request #47 from kristianwind/docs-and-check-docs
CI / Static checks (push) Failing after 28s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m18s
Document the plugin, and check that the documentation still describes it
2026-09-28 21:47:47 +02:00
15 changed files with 432 additions and 141 deletions
+6 -2
View File
@@ -4,7 +4,7 @@ Tags: woocommerce, discount, email, shipping, coupon
Requires at least: 5.8
Tested up to: 7.1.2
Requires PHP: 7.4
Stable tag: 1.6.3
Stable tag: 1.7.0
WC requires at least: 6.0
WC tested up to: 11.1.2
License: GPLv2 or later
@@ -50,7 +50,8 @@ coupons in a dropdown.
* Per discount group, choose which shipping methods are shown or hidden.
* No rules for a group means all shipping methods behave normally.
* If any method is set to "Show", only "Show" methods are offered (whitelist).
* If any method is set to "Show" or "Only", only those methods are offered to the group (whitelist).
* "Only" also hides the method from every customer outside the group; "Show" does not.
* Drag & drop reordering controls the order methods appear in at checkout.
= How discounts are applied =
@@ -96,6 +97,9 @@ The most specific match wins: exact email, then domain, then partial match.
== Changelog ==
= 1.7.0 =
* Shipping rules have a new choice, "Only". "Only" shows a method to the group and hides it from everybody else, which is what free delivery for one company needs. "Show" goes back to meaning just that: the group is offered the method, and nobody else is affected. Since 1.6.0 "Show" did both, so a group that was shown ordinary methods next to a free one took the ordinary ones away from every other customer. Every "Show" rule saved before this update is changed to "Only" automatically, so the checkout looks the same after updating. To give a group its ordinary methods without reserving them, set those to "Show" and leave the free ones on "Only".
= 1.6.3 =
* The WooCommerce version this plugin declares as tested is now the same in both places it is written. The readme still said 11.1.0 while the plugin itself said 11.1.2.
+255 -101
View File
@@ -31,52 +31,6 @@
* Usage: php ci/check-docs.php <plugin-root>
*/
/**
* MIN_SHIPPED_PHP_FILES from ci/plugin.conf. Refuses rather than defaulting:
* an unset floor parses to 0 and every comparison against it passes, which is
* the same bug as a scan over zero files one level up.
*/
function shipped_php_floor() {
$conf = dirname( __DIR__ ) . '/ci/plugin.conf';
if ( is_file( $conf ) && preg_match( '/^MIN_SHIPPED_PHP_FILES=([0-9]+)/m', (string) file_get_contents( $conf ), $m ) && (int) $m[1] > 0 ) {
return (int) $m[1];
}
fwrite( STDERR, "::error::No usable MIN_SHIPPED_PHP_FILES in ci/plugin.conf — refusing to run with no floor.\n" );
exit( 1 );
}
/**
* This plugin's own hook prefix, read from ci/plugin.conf.
*
* Refuses rather than guessing, and is deliberately NOT derived from
* TEXT_DOMAIN: here the domain is wc-email-discount and the hooks are wc_edm_,
* and in the tree this file came from the domain is nolimit-security and the
* hooks are nlsec_. A checker that guessed would be wrong in both.
*/
function hook_prefix() {
static $prefix = null;
if ( null !== $prefix ) {
return $prefix;
}
$conf = dirname( __DIR__ ) . '/ci/plugin.conf';
$prefix = '';
if ( is_file( $conf ) && preg_match( '/^HOOK_PREFIX=([A-Za-z0-9_]+)/m', (string) file_get_contents( $conf ), $m ) ) {
$prefix = strtolower( $m[1] );
}
if ( '' === $prefix ) {
fwrite( STDERR, "::error::No HOOK_PREFIX in ci/plugin.conf — refusing to guess which hooks are ours.\n" );
exit( 1 );
}
return $prefix;
}
/**
* Is this hook part of a contract, or is it internal wiring?
*
@@ -114,34 +68,27 @@ function hook_prefix() {
* anybody about), `nlsec_public_rest_routes` (a genuine extension point a form
* plugin needs when a shop locks REST down) and `nlsec_pending_wp_config`.
*
* @param string $call do_action | apply_filters | add_filter | add_action
* @param string $call One of do_action, apply_filters, add_filter, add_action.
* @param string $hook The hook name.
* @return bool
*/
function is_contract( $call, $hook ) {
// 🔴 From ci/plugin.conf, never a literal. The version of this file
// published as woo-security-manager 52dd99d asked plugin.conf for the
// prefix on the DOCUMENTED side (and refuses without it, correctly) and
// still tested the SOURCE side against a hardcoded 'nlsec_'. Ported here
// verbatim that is not a green-over-nothing — it is worse. Measured on this
// tree with docs/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 the check 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, which is the only thing that stopped
// it, and the porting instructions were to add HOOK_PREFIX and fix the
// floor. Doing as told is what removed the protection. Two wrongs
// cancelling into a pass, in the documented order.
$ours = 0 === strpos( $hook, hook_prefix() );
$family = 0 === strpos( $hook, 'wpmgr_' );
// 🔴 From the conf, never a literal. Hardcoding 'nlsec_' here is exactly
// the copied-kit trap this family has paid for twice: the check keeps
// working in the tree it was written in and silently classifies every hook
// in the next one as "not ours", so CONTRACT.md may be empty and the run
// is green. I wrote it as a literal first and caught it re-reading.
$prefix = hook_prefix();
$ours = 0 === strpos( $hook, $prefix );
$family = false;
foreach ( sibling_prefixes() as $other ) {
if ( 0 === strpos( $hook, $other ) ) {
$family = true;
break;
}
}
if ( ! $ours && ! $family ) {
return false;
@@ -156,14 +103,147 @@ function is_contract( $call, $hook ) {
return $family;
}
/**
* The shipped-file floor, from MIN_SHIPPED_PHP_FILES in ci/plugin.conf.
*
* Refuses rather than defaulting: an unset floor parses to 0 and every
* comparison against it passes, which is a scan over zero files one level up.
*
* @return int
*/
function shipped_php_floor() {
global $conf_root;
$conf = $conf_root . '/ci/plugin.conf';
if ( is_file( $conf ) && preg_match( '/^MIN_SHIPPED_PHP_FILES=([0-9]+)/m', (string) file_get_contents( $conf ), $m ) && (int) $m[1] > 0 ) {
return (int) $m[1];
}
fwrite( STDERR, "::error::No usable MIN_SHIPPED_PHP_FILES in ci/plugin.conf — refusing to run with no floor.\n" );
exit( 1 );
}
/**
* This plugin's own hook prefix, from ci/plugin.conf.
*
* Refuses rather than guessing. TEXT_DOMAIN is not it — here that is
* nolimit-security while the hooks are nlsec_ — and a checker that guessed
* would guess wrong in this tree and silently right in another, which is the
* worse of the two.
*
* @return string
*/
function hook_prefix() {
static $prefix = null;
if ( null !== $prefix ) {
return $prefix;
}
global $conf_root;
$conf = $conf_root . '/ci/plugin.conf';
if ( is_file( $conf ) && preg_match( '/^HOOK_PREFIX=([A-Za-z0-9_]+)/m', (string) file_get_contents( $conf ), $m ) ) {
$prefix = strtolower( $m[1] );
return $prefix;
}
fwrite( STDERR, "::error::No HOOK_PREFIX in ci/plugin.conf — refusing to guess which hooks are this plugin's.\n" );
exit( 1 );
}
/**
* Prefixes owned by the family, ours excluded.
*
* 🔴 Without this the check has a hole on the CONSUMER side, found by Nolimit
* Forms by deleting two rows and watching it stay green. The first version
* tested `ours || wpmgr_`, which is right in the tree that wrote it — `nlsec_`
* IS its prefix — and wrong everywhere else: in Forms' tree `nlsec_` is
* neither theirs nor `wpmgr_`, so the whole Forms/Security boundary fell into
* "core, not ours to document" and a green was printed over its absence.
*
* That is the class `wpmgr_` was widened to catch in the first place: a name a
* SIBLING owns that we answer. `wpmgr_` was one instance of it, not the rule.
*
* ⚠️ This list goes stale the day a ninth plugin appears — the same fault as
* the hardcoded prefix, one level out, and it is worth saying rather than
* leaving to be found. It belongs in FAMILY.md once that exists, read from one
* place by all eight. Until then it is one line per repo, visible, in the file
* that already holds everything plugin-specific.
*
* Named "sibling" rather than "family" because PHPCS's compatibility sniff
* reads a function starting `fam` as the removed PHP 5.1 `fam_*` extension and
* fails the build on it — a false positive, but one that costs the next person
* the same ten minutes.
*
* @return array
*/
function sibling_prefixes() {
static $list = null;
if ( null !== $list ) {
return $list;
}
global $conf_root;
$conf = $conf_root . '/ci/plugin.conf';
$raw = is_file( $conf ) ? (string) file_get_contents( $conf ) : '';
/*
* 🔴 Refuse when the line is absent, rather than defaulting to wpmgr_.
*
* The default used to BE array( 'wpmgr_' ) — which is exactly the narrow
* behaviour this key exists to remove. So a tree that copied the kit and
* forgot the line got the consumer-side hole straight back, silently, with
* a plausible number and a green, while HOOK_PREFIX three lines away
* refused. Nolimit Forms named it: **a default that is the pre-fix
* behaviour is a fix that switches itself off.**
*
* `FAMILY_PREFIXES=""` is how a plugin says it answers no sibling hooks.
* Same argument as HOOK_PREFIX_MIN=0: "none" and "nobody filled this in"
* are otherwise identical silences, and only one of the two is a decision.
*
* Quotes are stripped because ci/plugin.conf is SOURCED by every ci/*.sh,
* so a bare space-separated value makes the shell try to run the second
* word. The site test found that, reporting
* `ci/plugin.conf: line 89: nlfrm_: command not found` — a message naming
* neither this file nor the setting.
*/
if ( ! preg_match( '/^FAMILY_PREFIXES=(.*)$/m', $raw, $m ) ) {
fwrite( STDERR, "::error::No FAMILY_PREFIXES in ci/plugin.conf. Without it a hook a SIBLING owns falls into \"core, not ours to document\" and this check goes green over the absence of the whole boundary. List the other plugins' prefixes, or set FAMILY_PREFIXES=\"\" to say this plugin answers none.\n" );
exit( 1 );
}
$list = preg_split( '/[\s,]+/', trim( $m[1], " \t\"'" ), -1, PREG_SPLIT_NO_EMPTY );
// Ours is handled separately; leaving it here too is harmless but makes the
// two arms of is_contract() overlap, which reads as a bug to the next person.
$list = array_values( array_diff( $list, array( hook_prefix() ) ) );
return $list;
}
$root = isset( $argv[1] ) ? rtrim( $argv[1], '/' ) : '.';
$docs = $root . '/docs';
global $conf_root;
$conf_root = $root;
$fails = 0;
/**
* Record a problem and print it where CI will pick it up.
*
* @param string $message What is wrong, naming the file or hook.
* @return void
*/
function fail( $message ) {
global $fails;
$fails++;
++$fails;
printf( "::error::%s\n", $message );
}
@@ -193,7 +273,7 @@ foreach ( $required as $file => $floor ) {
continue;
}
$lines = count( file( $path, FILE_IGNORE_NEW_LINES ) );
$lines = count( file( $path, FILE_IGNORE_NEW_LINES ) );
$lines_total += $lines;
if ( $lines < $floor ) {
@@ -214,7 +294,7 @@ printf( " %d documents, %d lines.\n", count( $required ), $lines_total );
* Named value, not "a version is mentioned": a check that merely finds a
* version-shaped string passes on last year's.
*/
$mains = glob( $root . '/*.php' );
$mains = glob( $root . '/*.php' );
$version = '';
foreach ( (array) $mains as $main ) {
@@ -253,22 +333,21 @@ if ( 0 !== $rc ) {
exit( 1 );
}
$php = array_values( array_filter( $shipped, static function ( $f ) {
return '.php' === substr( $f, -4 );
} ) );
$php = array_values(
array_filter(
$shipped,
static function ( $f ) {
return '.php' === substr( $f, -4 );
}
)
);
// The floor comes from ci/plugin.conf, not from a literal. It arrived here as
// 20 — measured in the tree this file was written in — and refused to run in
// this 14-file one. A second hardcoded number per plugin is a second thing to
// re-measure on every port, and the conf already carries this one.
//
// The floor is not the fault: a copied kit that reads zero files prints a
// plausible number and passes. Refuse instead of reporting a green over
// nothing — and refuse rather than warn, because a warning next to a plausible
// number is read as a plausible number.
$min_php = shipped_php_floor();
if ( count( $php ) < $min_php ) {
// The floor is not the fault: a copied kit that reads zero files prints a
// plausible number and passes. Refuse instead of reporting a green over
// nothing.
fail( sprintf( 'Only %d shipped PHP files found, under the floor of %d. That is too few to be this tree — check the prefix and the paths before lowering this.', count( $php ), $min_php ) );
exit( 1 );
}
@@ -341,9 +420,31 @@ foreach ( $php as $file ) {
// floor gets lowered until the check reads nothing.
$contract = (string) file_get_contents( $docs . '/CONTRACT.md' );
/*
* 🔴 Table ROWS, not every backticked token in the file.
*
* Found by Nolimit Assistant from the other side and confirmed here by plant:
* delete a hook's table row, leave the name in one sentence of prose, and the
* first version printed "all 16 in docs/CONTRACT.md" and exited 0. A mention
* is not a row — the row carries the signature, what a caller gets when we are
* absent, and what a consumer must not do, and none of that is what a reader
* needs replaced by its own name in a sentence.
*
* It matters in the other direction too, which is how Assistant hit it: their
* first version read every backticked token and reported nine names as
* vanished hooks — two functions and seven WP_Error codes, every one of them
* correctly written in prose. Scoping to rows fixes both at once, and it makes
* the prose free: the tables are the contract, everything else is commentary,
* and commentary should not be able to satisfy or break a check.
*
* Verified against this document before narrowing: all 16 contract hooks are
* in rows, and the 30 backticked names that are not are field names, core
* hooks and the internal cron hooks listed under a heading that says they are
* explicitly not a contract.
*/
$documented = array();
if ( preg_match_all( '/`([a-z0-9_]+)`/', $contract, $m ) ) {
if ( preg_match_all( '/^\|[^|]*`([a-z0-9_]+)`/m', $contract, $m ) ) {
foreach ( $m[1] as $name ) {
$documented[ $name ] = true;
}
@@ -368,14 +469,63 @@ sort( $undocumented );
// rather than from a pattern over the hook names — a copied kit that guesses
// the prefix from its own contents is how a check ends up reading zero files
// in the next tree and printing a plausible number.
$prefix = '';
$prefix = hook_prefix();
if ( is_file( $root . '/ci/plugin.conf' ) && preg_match( '/^HOOK_PREFIX=([A-Za-z0-9_]+)/m', (string) file_get_contents( $root . '/ci/plugin.conf' ), $m ) ) {
$prefix = strtolower( $m[1] );
/*
* 🔴 A wrong prefix is GREEN, and that is the whole reason this block exists.
*
* Measured on this tree by setting HOOK_PREFIX=zzzz_ : the run printed
* "6 hooks in 46 shipped PHP files, all 6 in docs/CONTRACT.md" and exited 0.
* Sixteen became six — only the wpmgr_ names survived — and nothing looked
* wrong, because six is a plausible number and every one of the six really was
* documented. Two wrongs cancelling into a pass, in a checker I had just told
* seven sibling sessions to copy into trees with seven different prefixes.
*
* Same shape as the copied kit that read zero always-loaded files: the port is
* not done when plugin.conf is, and the error you can see is never the one
* that matters. So: refuse. A plugin may genuinely define no hooks of its own,
* but that is a thing to state rather than to arrive at by a typo.
*/
$own = 0;
foreach ( array_keys( $in_code ) as $hook ) {
if ( 0 === strpos( $hook, $prefix ) ) {
++$own;
}
}
if ( '' === $prefix ) {
fail( 'No HOOK_PREFIX in ci/plugin.conf — refusing to guess which documented names are ours.' );
$min_own = 1;
if ( is_file( $root . '/ci/plugin.conf' ) && preg_match( '/^HOOK_PREFIX_MIN=([0-9]+)/m', (string) file_get_contents( $root . '/ci/plugin.conf' ), $m ) ) {
$min_own = (int) $m[1];
}
if ( $own < $min_own ) {
// Nolimit Cache's improvement on the first version of this message, and it
// is the better one: say what IS there. "No hook carries nlsec_" leaves the
// reader to work out whether the scan found nothing at all or found the
// wrong things, and those need different fixes.
$present = array();
foreach ( array_keys( $in_code ) as $hook ) {
if ( preg_match( '/^([a-z]{2,8}_)/', $hook, $pm ) ) {
$present[ $pm[1] ] = isset( $present[ $pm[1] ] ) ? $present[ $pm[1] ] + 1 : 1;
}
}
$summary = array();
foreach ( $present as $seen => $count ) {
$summary[] = sprintf( '%s* (%d)', $seen, $count );
}
$message = sprintf(
'HOOK_PREFIX is %s* and no hook in the shipped source starts with it — the prefixes actually present are: %s. Either HOOK_PREFIX is wrong for this plugin, or this plugin defines none of its own; if it really defines none, say so in docs/CONTRACT.md and set HOOK_PREFIX_MIN=0 in ci/plugin.conf.',
$prefix,
$summary ? implode( ', ', $summary ) : 'none'
);
fail( $message );
exit( 1 );
}
@@ -418,19 +568,23 @@ foreach ( array_keys( $documented ) as $name ) {
sort( $stale );
if ( $undocumented ) {
fail( sprintf(
'%d hook(s) in the shipped source and not in docs/CONTRACT.md: %s',
count( $undocumented ),
implode( ', ', $undocumented )
) );
fail(
sprintf(
'%d hook(s) in the shipped source and not in docs/CONTRACT.md: %s',
count( $undocumented ),
implode( ', ', $undocumented )
)
);
}
if ( $stale ) {
fail( sprintf(
'%d name(s) in docs/CONTRACT.md that no longer exist in the source: %s',
count( $stale ),
implode( ', ', $stale )
) );
fail(
sprintf(
'%d name(s) in docs/CONTRACT.md that no longer exist in the source: %s',
count( $stale ),
implode( ', ', $stale )
)
);
}
if ( ! $undocumented && ! $stale ) {
+12 -1
View File
@@ -25,7 +25,18 @@ done < .distignore
# with nothing said. (-z also covers paths with spaces.) Found in a sibling
# plugin that has exactly such a filename; this repo has none today, which is
# precisely why it would have sat here until someone added one.
git ls-files -z | while IFS= read -r -d '' file; do
# In a git-less copy (ci_php's php container, which is handed exactly the
# tracked files and has no git) the tree IS the tracked set, so walk it.
# selftest.sh asserts the two forms print the same list.
tracked() {
if [ -e .git ]; then
git ls-files -z
else
find . -type f -print0 | sed -z 's|^\./||' | LC_ALL=C sort -z
fi
}
tracked | while IFS= read -r -d '' file; do
keep=1
for p in "${patterns[@]}"; do
# A bare name excludes the file or directory wherever it appears, which is
+3
View File
@@ -110,3 +110,6 @@ OLD_PLUGIN_NAME="WooCommerce Email Discount Manager"
# run reports green having tested two thirds of what it claims.
MIN_SITE_ASSERTIONS=60
HOOK_PREFIX=wc_edm_
# Hook prefixes owned by the sibling plugins. Quoted because this file is
# sourced. Read from each sibling's HOOK_PREFIX, not guessed.
FAMILY_PREFIXES="wpmgr_ nlfrm_ nlseo_ nlvw_ kws_ nlass_ nlcac_ nlsec_"
+32 -1
View File
@@ -400,6 +400,19 @@ else
fi
cp "$work/helpers.orig" includes/class-helpers.php
# 1b. the same fault for a hook a SIBLING owns and we answer. Before
# FAMILY_PREFIXES this fell into "core, not ours to document" and stayed green.
printf '\n// Added by ci/selftest.sh.\nadd_filter( %s, %s );\n' \
"'nlsec_selftest_sibling'" "'__return_true'" >> includes/class-helpers.php
if [ "$(grep -c 'nlsec_selftest_sibling' includes/class-helpers.php)" -lt 1 ]; then
bad "could not stage the sibling-hook fixture"
else
printf '%s' "$(docs_verdict)" | grep -q 'not in docs/CONTRACT.md: .*nlsec_selftest_sibling' \
&& ok "flagged a sibling-owned hook we answer and CONTRACT.md does not mention" \
|| bad "NOT FLAGGED: an undocumented sibling hook — the whole boundary to another plugin is invisible"
fi
cp "$work/helpers.orig" includes/class-helpers.php
# 2. 🔴 the control that matters: the same name, in a COMMENT only. The checker
# must stay GREEN, and this asserts the specific success line rather than an
# exit code — a refusal, a missing file or a fatal all exit non-zero too, and
@@ -451,11 +464,29 @@ else
fi
git checkout -q -- docs/README.md 2>/dev/null || cp "$work/README.md.orig" docs/README.md
# 5b. the file list must be the same without git. ci_php runs check-docs.php in
# a php container that is handed the tracked files and has no git; dist-files.sh
# used to exit 127 there, so the docs check failed on the Gitea runner alone.
nogit=$(mktemp -d)
git ls-files -z | tar --null -T - -cf - | tar -xf - -C "$nogit"
if [ -e "$nogit/.git" ]; then
bad "could not stage the git-less copy"
else
with_git=$(./ci/dist-files.sh)
without_git=$(cd "$nogit" && ./ci/dist-files.sh 2>&1 || echo "exit $?")
if [ -n "$with_git" ] && [ "$with_git" = "$without_git" ]; then
ok "dist-files.sh lists the same $(printf '%s\n' "$with_git" | wc -l | tr -d ' ') files with and without git"
else
bad "dist-files.sh differs without git — the docs check goes red on the Gitea runner only"
fi
fi
rm -rf "$nogit"
# 6. both conf values must refuse rather than default. An unset floor parses to
# 0 and every comparison against it passes, and a guessed prefix classifies
# every hook in the tree as somebody else's.
cp ci/plugin.conf "$work/conf.orig"
for key in HOOK_PREFIX MIN_SHIPPED_PHP_FILES; do
for key in HOOK_PREFIX MIN_SHIPPED_PHP_FILES FAMILY_PREFIXES; do
grep -v "^$key=" "$work/conf.orig" > ci/plugin.conf
if [ "$(grep -c "^$key=" ci/plugin.conf)" -ne 0 ]; then
bad "could not stage the missing-$key fixture"
+1 -1
View File
@@ -1,6 +1,6 @@
# Contract
Every hook this plugin offers, and every family hook it answers. Version 1.6.3.
Every hook this plugin offers, and every family hook it answers. Version 1.7.0.
A contract is a name somebody else has to know. Core hooks this plugin merely
listens to — `init`, `admin_menu`, `wp_enqueue_scripts` — are not here;
+6 -3
View File
@@ -1,6 +1,6 @@
# Nolimit Discounts
Version 1.6.3 · a WooCommerce plugin · slug `woocommerce-email-discount-manager`
Version 1.7.0 · a WooCommerce plugin · slug `woocommerce-email-discount-manager`
Gives a customer a discount, and decides which shipping methods they may
choose, based on the email address they check out with.
@@ -29,11 +29,14 @@ address, then domain, then fragment.
A discount group can be given shipping rules, which is the half worth reading
twice because it decides what a stranger sees.
A method a group is told to **show** is *reserved* for that group. Customers
A method a group is set to **only** is *reserved* for that group. Customers
who match no group do not see it. Before 1.6.0 a method set up for approved
company addresses stayed on the checkout for everybody, so free delivery meant
for one customer could be chosen by anyone who found it — the bug that release
fixed.
fixed. **Show** is the weaker rule and reserves nothing: it offers the method
to the group and leaves everyone else alone. 1.6.0 made show do both, which
took ordinary methods away from strangers whenever they were listed beside a
free one; the next release split them and turned every old show rule into only.
The plugin never leaves a customer with no shipping method at all. If applying
the rules would empty the list, every method comes back.
+3 -3
View File
@@ -1,6 +1,6 @@
# Releasing
One release a day at most, cut by hand. Version 1.6.3 is current.
One release a day at most, cut by hand. Version 1.7.0 is current.
## The sequence
@@ -45,7 +45,7 @@ is loud, an id that belongs to something else is not.
| --- | --- | --- |
| `ci/static.sh` | syntax, guards, text domain, no secrets, every loaded file ships, one version everywhere, the two compat files agree, no tracked file writes a git identity, the docs still describe this tree | a count on every passing line |
| `ci/selftest.sh` | **the checkers themselves** — see below | `all N assertions passed` |
| `ci/site-test.sh` | a real WordPress + WooCommerce + MySQL install, this plugin activated, `tests/smoke.php` run through `wp eval-file` | `All 69 assertions passed`, floored at 60 by the caller |
| `ci/site-test.sh` | a real WordPress + WooCommerce + MySQL install, this plugin activated, `tests/smoke.php` run through `wp eval-file` | `All 73 assertions passed`, floored at 60 by the caller |
| `.github/workflows/compat.yml` | the matrix: PHP 7.4 / 8.3 / 8.4, WP latest and nightly, WC latest and beta | one job per cell |
## The checkers are themselves tested, and that is unusual
@@ -233,7 +233,7 @@ derives everything from the header, so splitting them was a one-line change —
and it means the source repository's visibility is a separate decision from the
feed's.
⚠️ The source repository is **still public** as of 1.6.3. Making it private is
⚠️ The source repository is **still public** as of 1.7.0. Making it private is
safe only once no installation is still pointing its `Update URI` at the old
address; an install on a pre-1.5.0 build reads its feed from
`kw/nolimit-discounts`, and making that private would silently stop its
+9 -7
View File
@@ -53,13 +53,15 @@ A fragment pattern (`john`) matches `john@anywhere.com` and also
### Shipping
Per group, per shipping method, one of **show** or **hide**.
Per group, per shipping method, one of **show**, **only** 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.
**Show** offers the method to the group (and, once any method is shown, the
group is offered only the methods marked show or only). Nobody else is
affected. **Only** does the same and also *reserves* the method: customers
outside the group stop seeing it everywhere on the shop. That is how "free
delivery for approved addresses" stays unavailable to everybody else — set the
free methods to **only** and the ordinary ones (parcel shops, pickup) to
**show**. Adding one only rule changes what strangers see.
## How to tell it is working
@@ -81,7 +83,7 @@ Not "no errors". A specific number on a specific screen:
4. **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
method marked only 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.
+6 -2
View File
@@ -51,7 +51,7 @@ class WC_EDM_Admin_Shipping {
$action = isset( $raw_rules[ $method_id ] ) ? $raw_rules[ $method_id ] : '';
if ( ! in_array( $action, array( 'show', 'hide' ), true ) ) {
if ( ! in_array( $action, array( 'show', 'hide', 'only' ), true ) ) {
continue;
}
@@ -84,7 +84,7 @@ class WC_EDM_Admin_Shipping {
?>
<h2><?php esc_html_e( 'Shipping Rules', 'wc-email-discount' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Choose which shipping methods are shown or hidden for customers in a discount group. If a group has no rules, all shipping methods behave normally. If any method is set to "Show", only "Show" methods are offered.', 'wc-email-discount' ); ?>
<?php esc_html_e( 'Choose which shipping methods are shown or hidden for customers in a discount group. If a group has no rules, all shipping methods behave normally. If any method is set to "Show" or "Only", only those methods are offered to the group. "Only" also hides the method from every customer outside the group, which is what free delivery for one company needs; "Show" leaves it available to everybody.', 'wc-email-discount' ); ?>
</p>
<?php if ( empty( $groups ) ) : ?>
@@ -175,6 +175,10 @@ class WC_EDM_Admin_Shipping {
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="show" <?php checked( $action, 'show' ); ?>>
<?php esc_html_e( 'Show', 'wc-email-discount' ); ?>
</label>
<label title="<?php esc_attr_e( 'Shown to this group, and hidden from everybody else', 'wc-email-discount' ); ?>">
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="only" <?php checked( $action, 'only' ); ?>>
<?php esc_html_e( 'Only', 'wc-email-discount' ); ?>
</label>
<label>
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="hide" <?php checked( $action, 'hide' ); ?>>
<?php esc_html_e( 'Hide', 'wc-email-discount' ); ?>
+37 -7
View File
@@ -77,7 +77,7 @@ class WC_EDM_DB {
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
group_id bigint(20) unsigned NOT NULL,
shipping_method_id varchar(191) NOT NULL,
action enum('show','hide') NOT NULL DEFAULT 'show',
action enum('show','hide','only') NOT NULL DEFAULT 'show',
sort_order int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY group_id (group_id)
@@ -102,10 +102,37 @@ class WC_EDM_DB {
dbDelta( $sql );
self::add_foreign_keys();
self::migrate_show_to_only();
update_option( 'wc_edm_db_version', WC_EDM_VERSION );
}
/**
* Once, when "only" was introduced: every existing "show" rule was reserved
* for its group, so it becomes "only" and the shop's checkout looks exactly
* as it did. Without this the update would silently put reserved free
* delivery back in front of every stranger.
*/
private static function migrate_show_to_only() {
global $wpdb;
if ( get_option( 'wc_edm_show_to_only_done' ) ) {
return;
}
$table = self::table( 'shipping_rules' );
$column = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'action'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// dbDelta did not widen the enum: converting now would store '' rather
// than 'only'. Leave the flag unset so the next load tries again.
if ( ! $column || false === strpos( (string) $column->Type, "'only'" ) ) {
return;
}
$wpdb->query( "UPDATE {$table} SET action = 'only' WHERE action = 'show'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
update_option( 'wc_edm_show_to_only_done', 1 );
}
/**
* Run create_tables() again when the plugin was updated without
* re-activation (dbDelta adds any missing columns).
@@ -510,10 +537,13 @@ 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.
* An "only" rule means the group sees that method AND 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. A plain "show"
* rule is a whitelist for its own group and reserves nothing: 1.6.0 made
* "show" do both, and a group that needed to show ordinary methods next to
* a reserved one took those ordinary methods away from everybody else.
*
* @return string[] Rate ids, e.g. "free_shipping:15".
*/
@@ -521,7 +551,7 @@ class WC_EDM_DB {
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
$ids = $wpdb->get_col( "SELECT DISTINCT shipping_method_id FROM {$table} WHERE action = 'only'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return is_array( $ids ) ? $ids : array();
}
@@ -538,7 +568,7 @@ class WC_EDM_DB {
$wpdb->delete( self::table( 'shipping_rules' ), array( 'group_id' => $group_id ), array( '%d' ) );
foreach ( $rules as $rule ) {
if ( empty( $rule['shipping_method_id'] ) || ! in_array( $rule['action'], array( 'show', 'hide' ), true ) ) {
if ( empty( $rule['shipping_method_id'] ) || ! in_array( $rule['action'], array( 'show', 'hide', 'only' ), true ) ) {
continue;
}
$wpdb->insert(
+6 -4
View File
@@ -340,7 +340,7 @@ class WC_EDM_Frontend {
//
// 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
// a "show" rule (now "only") 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
@@ -389,7 +389,9 @@ class WC_EDM_Frontend {
* Apply a group's show/hide shipping rules.
*
* If the group has no rules, all rates pass through untouched.
* "show" rules act as a whitelist; "hide" rules remove rates.
* "show" and "only" rules act as a whitelist; "hide" rules remove rates.
* "only" additionally reserves the method for the group (see
* WC_EDM_DB::restricted_shipping_methods()).
* Rates are reordered to follow the rules' sort order.
*
* @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate).
@@ -413,7 +415,7 @@ class WC_EDM_Frontend {
$hide = array();
foreach ( $rules as $rule ) {
if ( 'show' === $rule->action ) {
if ( 'show' === $rule->action || 'only' === $rule->action ) {
$show[] = $rule->shipping_method_id;
} else {
$hide[] = $rule->shipping_method_id;
@@ -422,7 +424,7 @@ class WC_EDM_Frontend {
$filtered = array();
// Reserved by some group, but not by THIS one.
// Reserved ("only") by some group, but not shown to THIS one.
$others = array_diff( WC_EDM_DB::restricted_shipping_methods(), $show );
foreach ( $rates as $rate_id => $rate ) {
+2 -2
View File
@@ -709,10 +709,10 @@ class WC_EDM_REST_API {
$method_id = isset( $rule['shipping_method_id'] ) ? sanitize_text_field( (string) $rule['shipping_method_id'] ) : '';
$action = isset( $rule['action'] ) ? sanitize_key( (string) $rule['action'] ) : '';
if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide' ), true ) ) {
if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide', 'only' ), true ) ) {
return new WP_Error(
'wc_edm_invalid',
__( 'Each rule needs a shipping_method_id and an action of "show" or "hide".', 'wc-email-discount' ),
__( 'Each rule needs a shipping_method_id and an action of "show", "hide" or "only".', 'wc-email-discount' ),
array( 'status' => 400 )
);
}
+52 -5
View File
@@ -184,7 +184,7 @@ WC_EDM_DB::replace_shipping_rules(
// shape. A rule holding a bare method id silently matches nothing, and the
// "never leave the customer without shipping" fallback then hands back
// every rate, so the misconfiguration looks exactly like no rule at all.
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ) )
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ) )
);
$rates = array(
@@ -194,7 +194,7 @@ $rates = array(
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 ) );
wc_edm_is( 'an "only" 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
@@ -212,8 +212,8 @@ wc_edm_is(
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 ),
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ),
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'only', 'sort_order' => 1 ),
)
);
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
@@ -221,7 +221,7 @@ 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 ) )
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ) )
);
// A SECOND group, with no rules of its own. Its members must not see what the
@@ -267,6 +267,53 @@ wc_edm_ok(
'saw: ' . implode( ', ', array_keys( $filtered ) )
);
// 🔴 "show" is a whitelist for the group and reserves NOTHING. 1.6.0 made it
// do both, so a shop that listed ordinary methods next to a free-delivery one
// took the ordinary ones away from every stranger and left them only the
// free one (reported live: strangers saw nothing but "Fri levering").
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' => 'only', 'sort_order' => 1 ),
)
);
WC()->customer->set_billing_email( 'nobody@nowhere.test' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
wc_edm_is(
'a stranger keeps a "show" method and loses the "only" one',
array( 'flat_rate:1', 'flat_rate:9' ),
array_keys( $filtered )
);
WC()->customer->set_billing_email( 'someone@company.com' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
wc_edm_is(
'a group member sees both its "show" and its "only" method, in rule order',
array( 'flat_rate:1', 'local_pickup:2' ),
array_keys( $filtered )
);
// The update from 1.6.x: rows written as "show" were reserved, and must stay so.
global $wpdb;
$wc_edm_table = WC_EDM_DB::table( 'shipping_rules' );
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
$wpdb->insert( $wc_edm_table, array( 'group_id' => (int) $fixtures['group_id'], 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', 'sort_order' => 0 ) );
delete_option( 'wc_edm_show_to_only_done' );
WC_EDM_DB::create_tables();
wc_edm_is(
'an update turns every old "show" rule into "only"',
array( 'only' ),
$wpdb->get_col( $wpdb->prepare( "SELECT action FROM {$wc_edm_table} WHERE group_id = %d", (int) $fixtures['group_id'] ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
$wpdb->update( $wc_edm_table, array( 'action' => 'show' ), array( 'shipping_method_id' => 'local_pickup:2' ) );
WC_EDM_DB::create_tables();
wc_edm_is(
'and does it once only: a later "show" stays "show"',
array( 'show' ),
$wpdb->get_col( $wpdb->prepare( "SELECT action FROM {$wc_edm_table} WHERE group_id = %d", (int) $fixtures['group_id'] ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
WC()->customer->set_billing_email( 'someone@company.com' );
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
+2 -2
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.6.3
* Version: 1.7.0
* Author: Kristian Wind
* Author URI: https://gitea.nolimit.dk/kw
* License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'WC_EDM_VERSION', '1.6.3' );
define( 'WC_EDM_VERSION', '1.7.0' );
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );