Imported from Automattic/woocommerce-services (
AGENTS.md). Install upstream withnpx skills add Automattic/woocommerce-services. Copyright stays with the author.
Agent Instructions
Product Scope
- CRITICAL: This repository is now Woo Tax-first; active maintenance and new development focus on tax functionality.
- CRITICAL: Shipping functionality is deprecated for new installs.
- CRITICAL: Shipping label purchase functionality remains supported only for grandfathered installs that purchased at least one label before deprecation.
- CRITICAL: Live rates remain supported only for grandfathered accounts.
- MUST NOT introduce new shipping product surface area for non-grandfathered installs.
Project Layout
- PHP plugin bootstrap:
woocommerce-services.php - PHP classes:
classes/andsrc/ - JS client code:
client/ - Build artifacts:
dist/ - Tests:
tests/
Package Managers
- Node/npm:
npm install,npm run dist,npm run test-client,npm run eslint - Composer:
composer install,composer test,composer check-all(PHPCS — see Linting)
Runtime Compatibility
- CRITICAL: This repo is tied to an old Node runtime; use
.nvmrc(10.18.1) for local work unless a migration is explicitly planned. - CRITICAL: The old Node requirement is intentional because this project depends on a forked legacy Calypso package in the
wp-calypsosubmodule (package name:wp-calypso). - CRITICAL: Always run
source ~/.nvm/nvm.sh && nvm usebefore any command that invokes Node (e.g.,npm install,npm run,git push— the pre-push hook runsnpm test). Without this, tests will fail with Node version incompatibilities. - MUST NOT perform incidental Node/toolchain upgrades while making feature or bugfix changes.
Common Commands
npm install
composer install
npm run dist
npm run test-client
npm run eslint
composer test # PHPUnit; auto-checks the test env and repairs only what's broken
composer test:setup # one-shot: bring up the Dockerized DB + install WP/WC test deps
composer check-all # PHPCS (see Linting below)
PHPUnit test environment
composer testis the single entry point. It runstests/bin/run-tests.sh, which health-checks the environment (tests/bin/check-test-env.sh) and repairs only the components that fail their probe before running PHPUnit. No manual setup step is needed on a healthy machine.- Output format:
composer testuses PHPUnit's compact progress output when its stdout is captured (CI logs, pre-commit hooks, AI agents) and the readable--testdoxformat at an interactive terminal. Force it either way withTESTDOX=1/TESTDOX=0(e.g.TESTDOX=1 composer test). - The test DB is a throwaway MariaDB container defined in
tests/docker-compose.yml(published on127.0.0.1:4416; credentials intests/test.env). Docker is required; a local mysql client is not (the installer materialises shims that proxy through the container when no client is present). composer test:setupinstalls/repairs every missing component; for a full rebuild that also wipes the DB volume runbash tests/bin/install-wc-tests.sh --force.- WP/WC are installed under the system temp dir (honors
TMPDIR). The WC clone builds with its own modern Node via the WC checkout's.nvmrc; this is independent of the plugin's own pinned Node (.nvmrc= 10.18.1) and PHPUnit needs no Node at all. - By default the installer clones the latest WooCommerce release, whereas CI tests
the 3 latest WC minors — so a local
composer testcan run against a different WC than the merge gate. To reproduce a specific version, pin it on a forced (re)install:WC_VERSION=9.4.0 bash tests/bin/install-wc-tests.sh --force(EXPECTED_WP_VERSIONpins WordPress the same way).
Linting (PHPCS) — split by concern
The PHPCS ruleset is split into concern-specific files, all importing .phpcs.common.xml (shared file/exclude/arg/config). Run check-all (the phpcs.xml.dist aggregator) before pushing, or a single scope while iterating:
composer run check-all # Aggregator (phpcs.xml.dist): runs security + identity + php together
composer run check-php # Generic PHP rules only (WC-Core + WP-Core/Extra/Docs, minus security & i18n)
composer run check-php:fix # Auto-fix for the check-php scope
composer run check-security # Security sniffs (escaping, sanitization, nonces, eval)
composer run check-identity # Plugin identity: text_domain (i18n) + global prefix whitelist
The husky pre-commit hook (bin/wc-phpcbf.sh via lint-staged) auto-fixes staged PHP using the same phpcs.xml.dist aggregator, so pre-commit fixes stay aligned with check-all.
Shell Script Portability (GNU vs BSD)
The test-env scripts (tests/bin/*.sh) and hook helpers (bin/*.sh) run on Linux/CI (GNU userland) and on contributors' machines (stock macOS ships BSD grep/sed). CI is GNU-only, so BSD-only breakage never shows up in the merge gate — keep both in mind when editing any shell script:
- Use POSIX character classes, not GNU regex shorthands:
[[:space:]]/[[:digit:]]/[[:alnum:]]instead of\s/\d/\w. BSDgrep -E/sed -Etreat\sas a literals, so patterns silently stop matching — this cost us a full-reinstall misfire in thewp-tests-config.phpDB_HOST probe. sed -iis not portable: GNU issed -i, BSD needs a backup suffix (sed -i .bak). Branch on[[ "$(uname -s)" == 'Darwin' ]]and remove the.bakafterward — seerepair_wp_config()ininstall-wc-tests.sh.- Prefer ERE (
grep -E/sed -E) with POSIX quantifiers; avoid GNU-only escapes like\+,\?,\|in BRE. When a script carries regex, test it on macOS or reason through the BSD case before merging.
Key Conventions
- Base new behavior on tax-only mode rules and existing shipping eligibility checks.
- Keep shipping logic gated by eligibility; preserve grandfathered behavior.
- Keep changes in source files; do not hand-edit generated build outputs in
dist/. - Follow existing WordPress/WooCommerce coding standards and linting rules in this repository.
Backward Compatibility
Any change to a public or externally exposed class, interface, function, or method signature is high-risk and must state its backward-compatibility impact in the PR description - regardless of which namespace the symbol lives in. The WC_Connect_* classes live in the global namespace and are referenced by third-party code; treat all of them as externally exposed. Newer code under Automattic\WCServices\* is not automatically safe either: a modern namespace is a code-organization choice, not a privacy guarantee, and anything reachable from outside can already have consumers.
Treat a symbol as externally exposed when it is implemented or consumed outside this repository - by other plugins, themes, or site snippets - even if it looks internal. When in doubt, assume it is exposed and state the BC impact.
Adding a method to an interface that external code can implement must be flagged explicitly. It is a backward-incompatible change: existing implementers fatal on load because they no longer satisfy the contract. Likewise, removing a required method from an interface is breaking for existing implementers (they carry a now-dead method, which static analysis such as PHPStan will flag). Prefer a non-breaking alternative: add the method to the concrete class rather than the interface, introduce a separate new interface, or supply a default implementation via an abstract base class.
Deprecate, don't rename. For existing public symbols (classes, interfaces, methods, constants, hooks), never rename or remove them in place. Mark the old symbol @deprecated, introduce the replacement alongside it, and keep both working through a deprecation window so external consumers have time to migrate.
This rule exists because WooCommerce 10.9.0 was reverted on WP Cloud: a required method added to a published interface fataled every older WooCommerce Stripe Gateway version that implemented it. The same failure mode applies to any contract this plugin publishes.
Deprecation also cuts the other way here: because shipping is compatibility-only for grandfathered installs, removing or narrowing an existing shipping surface is a BC change for those stores, not cleanup. See Product Scope.
The compatibility surface is wider than PHP signatures
WordPress exposes more contracts than class and function signatures. The following are equally binding: a change to any of them is high-risk and requires the same backward-compatibility impact statement in the PR description.
Hooks and filters are public contracts. Every do_action and apply_filters call is an interface that third-party callbacks depend on, including this plugin's wc_connect_*, wc_services_*, wcservices_*, and wcship_* hooks. Removing a hook, renaming it, or removing/reordering its arguments breaks every attached callback. Changing when or whether a hook fires can break consumers that depend on its timing. Additive is the safe path: append new arguments at the end, never remove or reorder existing ones. To retire a hook, fire it through do_action_deprecated() / apply_filters_deprecated() for a deprecation window instead of deleting it.
Never trust data that flows through hooks. Keep hook callback parameters untyped and validate or coerce the value before passing it to strictly typed code, since any callback can receive a value another one produced. And when firing a filter, validate the final return value before using it, since any callback in the chain can return the wrong thing.
Overridable classes are contracts too, including which internal methods get called. Third-party code extends the global WC_Connect_* classes (the abstract WC_REST_Connect_Base_Controller exists to be subclassed) and overrides individual methods, so the public and protected methods of any non-final class are contracts: changing their signatures or removing them breaks subclasses. Adding a fast path or skip that avoids calling an overridable method silently disables those overrides even though no signature changed: the override simply stops running. When optimizing such a class, ensure overridable methods are still invoked on every code path, or treat the change as breaking.
Registered script and style handles are public contracts. Third-party code enqueues this plugin's handles, lists them as dependencies, or dequeues them by name - including handles that were only ever registered incidentally. Renaming or removing a handle breaks those consumers. To rename with a compatibility window, register the legacy handle as an alias that depends on the new handle (the same pattern WordPress core uses for jquery → jquery-core); do not register the same file under both handles, or pages with mixed consumers will load it twice.
Do not assume global state. Tax calculation is reachable from the cart and checkout, the Store API, the REST API, cron, WP-CLI, and webhooks, and not all of those set the globals a front-end request does ($post, $wp_query, an initialized session or cart). A newly introduced read of a global, or of WC()->... state, in a path reachable outside a standard request is a fatal or a silent misbehavior in the contexts that do not set it - REST order updates are a repeat offender in this repo. Guard the exact dependency explicitly: use function_exists/class_exists for symbols, isset for variables, did_action for lifecycle state, and verify that WC() and the required component are initialized before dereferencing WC()->....
Do not assume single-site. Multisite changes where data lives: site-scoped vs network-scoped options (get_option vs get_site_option), per-site tables, user roles and capabilities, and upload paths all differ. This plugin's settings, service schemas, and connection state are site-scoped, so a network-activated install holds one set per site. A change that reads or writes site state must state in its PR whether it behaves correctly under multisite - and if it was not tested there, say so explicitly.
Do not assume install layout. WordPress could be configured to run in a subdirectory, with relocated wp-content, and behind reverse proxies. Never build paths or URLs by concatenation from the domain root; derive them (plugins_url(), plugin_dir_path(), wp_upload_dir(), and mind the home_url() vs site_url() distinction). This matters for dist/ asset URLs and for any store URL sent to the Connect server. A path that works on a root install and breaks elsewhere is a compatibility bug, not an edge case.
Before changing any public or externally exposed surface (agent checklist)
- Identify the contract you are touching: signature, hook, global/scope expectation, site topology, or install layout.
- Assume unseen consumers. You cannot enumerate third-party code; if the surface is reachable from outside this plugin, someone consumes it.
- Prefer the additive path (new optional method, appended hook argument, new symbol + deprecation) over changing what exists.
- State the impact in the PR description: what changed, who could consume it, and why it is safe or what the deprecation path is.
- If you cannot establish the impact, stop and flag it to the user as needing review.
Architecture Notes
has_only_tax_functionality()andshould_load_shipping_features()inwoocommerce-services.phpare core gates for tax-only vs shipping-enabled behavior.- Shipping behavior must remain compatibility-only for eligible legacy stores.
- Tax behavior is the primary development path.
Common Pitfalls
- Accidentally enabling shipping UI/features for non-grandfathered stores.
- Regressing legacy label purchase support for eligible installs.
- Treating shipping migrations as feature expansion instead of compatibility maintenance.
Local Skills
- Use
create-prfor PR preparation workflows. See.agents/skills/create-pr/SKILL.md. - Use
write-changelogfor changelog entry workflows. See.agents/skills/write-changelog/SKILL.md.