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>
112 lines
6.2 KiB
Markdown
112 lines
6.2 KiB
Markdown
# Contract
|
|
|
|
Every hook this plugin offers, and every family hook it answers. Version 1.6.3.
|
|
|
|
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;
|
|
WordPress documents those, and listening to `init` is not a promise to anyone.
|
|
The exception is at the bottom, and it is there because it is shared with every
|
|
sibling plugin on the same update host.
|
|
|
|
The tables and the source are compared in both directions by
|
|
`ci/check-docs.php`, which runs in CI. A hook added without a row here fails
|
|
the build, and so does a row whose name no longer exists in the source.
|
|
|
|
## Hooks this plugin provides
|
|
|
|
All six are filters, all read a setting that has a working default, and all are
|
|
answered from `apply_filters()` in shipped code. A shop that adds no code gets
|
|
the default and never needs this table.
|
|
|
|
| hook | signature | default | if you are absent |
|
|
| --- | --- | --- | --- |
|
|
| `wc_edm_audit_log_enabled` | `bool $enabled` | `true` | the audit log records admin actions |
|
|
| `wc_edm_audit_log_retention_days` | `int $days` | `30` | rows older than 30 days are pruned daily; the value is floored at 1, so `0` means 1 day, not "keep forever" |
|
|
| `wc_edm_client_ip` | `string $ip` | the resolved address | the plugin's own resolution is used — override this behind a proxy |
|
|
| `wc_edm_rate_limit_enabled` | `bool $enabled` | `true` | REST routes are rate limited |
|
|
| `wc_edm_rate_limit_max_requests` | `int $default, string $method` | per method | the built-in allowance applies; floored at 1 |
|
|
| `wc_edm_rate_limit_window` | `int $seconds, string $method` | `MINUTE_IN_SECONDS` | a 60-second window; floored at 1 |
|
|
|
|
**What a consumer must not do.** Do not return a non-scalar: each of these is
|
|
cast (`(bool)`, `(int)`, `(string)`) and a bad type becomes a wrong value rather
|
|
than an error. Do not assume `wc_edm_rate_limit_*` can switch limiting off by
|
|
returning `0` — they are floored at 1; use `wc_edm_rate_limit_enabled`.
|
|
Returning `false` from `wc_edm_audit_log_enabled` on a shop that has a
|
|
compliance reason to keep the log is a decision, not a performance tweak.
|
|
|
|
## The family's shared hooks
|
|
|
|
These cross a repository boundary, which is the class of value nobody's checks
|
|
cover, so both directions are written out.
|
|
|
|
| hook | this plugin | what that means |
|
|
| --- | --- | --- |
|
|
| `wpmgr_menu_title` | **answers** it (main file) and **provides** it (our copy of `shared-menu.php`) | returns `Nolimit`, so the shared parent menu is labelled the same whichever sibling registered it |
|
|
| `wpmgr_dashboard_cards` | **answers** it (`class-admin.php`) and **provides** it (our copy of `shared-menu.php`) | adds this plugin's card — colour `#1a7a52`, two counts, cached 5 minutes |
|
|
| `wpmgr_menu_glyph` | **provides** it only, via our copy of `shared-menu.php` | this plugin does not override the glyph; it answers with the shared default `∞` |
|
|
|
|
**Why "provides" and "answers" are both true.** `includes/shared-menu.php` is
|
|
byte-identical in all eight family plugins and frozen at a SHA in
|
|
`ci/plugin.conf`. Whichever copy loads first is the one that runs, so this
|
|
plugin is simultaneously one of the eight possible providers of these three
|
|
hooks and a consumer of whichever copy won. That is why the label is set with a
|
|
filter and not by editing the shared file: editing it would make a site with
|
|
three of eight plugins updated show a different menu title depending on
|
|
activation order, and change its mind when a plugin is deactivated.
|
|
|
|
**What a consumer must not do.** Do not edit `shared-menu.php` to change any of
|
|
these — it reaches every site in activation order and the frozen SHA will fail
|
|
the build in all eight trees. Answer the filter from your own plugin.
|
|
`wpmgr_menu_title` must be idempotent: every sibling adds a callback and the
|
|
order is not defined.
|
|
|
|
## One core hook, because it is shared
|
|
|
|
| hook | this plugin | why it is in a contract document |
|
|
| --- | --- | --- |
|
|
| `update_plugins_{$hostname}` | **answers** it, `add_filter( …, 10, 3 )` | 🔴 the filter name is built from the *hostname*, not the repository, so every plugin updating from `gitea.nolimit.dk` lands on the same hook and WordPress runs every callback for every one of them |
|
|
|
|
Answer it unconditionally and you offer your release to your neighbour. On a
|
|
live shop in September 2026 the subscriptions plugin was told version 1.4.4 was
|
|
an update for itself; 1.4.4 was this plugin's. Load order decides who wins, so
|
|
a rename is enough to flip which plugin gets corrupted — and flipped toward one
|
|
with auto-updates on, it installs a stranger's zip over itself unattended.
|
|
|
|
The guard is two halves and both are required:
|
|
|
|
```php
|
|
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
|
|
|
// and inside offer_update():
|
|
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
|
|
return $update;
|
|
}
|
|
```
|
|
|
|
Leave the argument count at 2 and `$plugin_file` never arrives, so the guard
|
|
silently does nothing — the same shape of bug it fixes. **No integration test
|
|
can catch this**, because a test site installs one plugin from that host and the
|
|
collision cannot arise; the assertion is the defence.
|
|
|
|
`plugins_api` is also answered and is safe, because it guards on `$args->slug`.
|
|
|
|
## What the checker cannot see, and is therefore listed by hand
|
|
|
|
`ci/check-docs.php` reads hook names out of the shipped source as string
|
|
literals. Two names in this tree are not literals at the call site:
|
|
|
|
| hook | how it is written | why it is missed |
|
|
| --- | --- | --- |
|
|
| `wc_edm_prune_audit_log` | `add_action( self::CRON_HOOK, … )` | a class constant, resolved at runtime |
|
|
| `update_plugins_{$hostname}` | `add_filter( "update_plugins_{$host}", … )` | interpolated from a variable |
|
|
|
|
Both rows above are real and both would survive a rename of the constant or the
|
|
variable without the checker noticing. `wc_edm_prune_audit_log` is the daily
|
|
audit-log prune event; it is internal wiring rather than an extension point,
|
|
but it is a name that appears in `wp_next_scheduled()` output and in any cron
|
|
viewer, so it is written down.
|
|
|
|
This section exists because the instrument's blind spot is worth stating next
|
|
to its results. A generated list is better than a remembered one, and it is
|
|
still a list of what the generator can see.
|