diff --git a/.agents/skills/wordpress-router/SKILL.md b/.agents/skills/wordpress-router/SKILL.md new file mode 100644 index 0000000..9405f4c --- /dev/null +++ b/.agents/skills/wordpress-router/SKILL.md @@ -0,0 +1,51 @@ +--- +name: wordpress-router +description: "Use when the user asks about WordPress codebases (plugins, themes, block themes, Gutenberg blocks, WP core checkouts) and you need to quickly classify the repo and route to the correct workflow/skill (blocks, theme.json, REST API, WP-CLI, performance, security, testing, release packaging)." +compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+). Filesystem-based agent with bash + node. Some workflows require WP-CLI." +--- + +# WordPress Router + +## When to use + +Use this skill at the start of most WordPress tasks to: + +- identify what kind of WordPress codebase this is (plugin vs theme vs block theme vs WP core checkout vs full site), +- pick the right workflow and guardrails, +- delegate to the most relevant domain skill(s). + +## Inputs required + +- Repo root (current working directory). +- The user’s intent (what they want changed) and any constraints (WP version targets, WP.com specifics, release requirements). + +## Procedure + +1. Run the project triage script: + - `node skills/wp-project-triage/scripts/detect_wp_project.mjs` +2. Read the triage output and classify: + - primary project kind(s), + - tooling available (PHP/Composer, Node, @wordpress/scripts), + - tests present (PHPUnit, Playwright, wp-env), + - any version hints. +3. Route to domain workflows based on user intent + repo kind: + - For the decision tree, read: `skills/wordpress-router/references/decision-tree.md`. +4. Apply guardrails before making changes: + - Confirm any version constraints if unclear. + - Prefer the repo’s existing tooling and conventions for builds/tests. + +## Verification + +- Re-run the triage script if you create or restructure significant files. +- Run the repo’s lint/test/build commands that the triage output recommends (if available). + +## Failure modes / debugging + +- If triage reports `kind: unknown`, inspect: + - root `composer.json`, `package.json`, `style.css`, `block.json`, `theme.json`, `wp-content/`. +- If the repo is huge, consider narrowing scanning scope or adding ignore rules to the triage script. + +## Escalation + +- If routing is ambiguous, ask one question: + - “Is this intended to be a WordPress plugin, a theme (classic/block), or a full site repo?” diff --git a/.agents/skills/wordpress-router/references/decision-tree.md b/.agents/skills/wordpress-router/references/decision-tree.md new file mode 100644 index 0000000..6d72cbb --- /dev/null +++ b/.agents/skills/wordpress-router/references/decision-tree.md @@ -0,0 +1,55 @@ +# Router decision tree (v1) + +This is a lightweight routing guide. It assumes you can run `wp-project-triage` first. + +## Step 1: classify repo kind (from triage) + +Use `triage.project.kind` and the strongest signals: + +- `wp-core` → treat as WordPress core checkout work (core patches, PHPUnit, build tools). +- `wp-site` → treat as a full site repo (wp-content present; changes might be theme + plugins). +- `wp-block-theme` → theme.json/templates/patterns workflows. +- `wp-theme` → classic theme workflows (templates PHP, `functions.php`, `style.css`). +- `wp-block-plugin` → Gutenberg block development in a plugin (block.json, build pipeline). +- `wp-plugin` / `wp-mu-plugin` → plugin workflows (hooks, admin, settings, cron, REST, security). +- `gutenberg` → Gutenberg monorepo workflows (packages, tooling, docs). + +If multiple kinds match, prefer the most specific: +`gutenberg` > `wp-core` > `wp-site` > `wp-block-theme` > `wp-block-plugin` > `wp-theme` > `wp-plugin`. + +## Step 2: route by user intent (keywords) + +Route by intent even if repo kind is broad (like `wp-site`): + +- **Interactivity API / data-wp-* directives / @wordpress/interactivity / viewScriptModule** + - Route → `wp-interactivity-api`. +- **Abilities API / wp_register_ability / wp-abilities/v1 / @wordpress/abilities** + - Route → `wp-abilities-api`. +- **Playground / run-blueprint / build-snapshot / @wp-playground/cli / playground.wordpress.net** + - Route → `wp-playground`. +- **Blocks / block.json / registerBlockType / attributes / save serialization** + - Route → `wp-block-development`. +- **theme.json / Global Styles / templates/*.html / patterns/** + - Route → `wp-block-themes`. +- **Plugins / hooks / activation hook / uninstall / Settings API / admin pages** + - Route → `wp-plugin-development`. +- **REST endpoint / register_rest_route / permission_callback** + - Route → `wp-rest-api`. +- **WP-CLI / wp-cli.yml / commands** + - Route → `wp-wpcli-and-ops`. +- **Build tooling / @wordpress/scripts / webpack / Vite / npm scripts** + - Route → `wp-build-tooling` (planned). +- **Testing / PHPUnit / wp-env / Playwright** + - Route → `wp-testing` (planned). +- **PHPStan / static analysis / phpstan.neon / phpstan-baseline.neon** + - Route → `wp-phpstan`. +- **Performance / caching / query profiling / editor slowness** + - Route → `wp-performance`. +- **Security / nonces / capabilities / sanitization/escaping / uploads** + - Route → `wp-security` (planned). + +## Step 3: guardrails checklist (always) + +- Verify detected tooling before suggesting commands (Composer vs npm/yarn/pnpm). +- Prefer existing lint/test scripts if present. +- If version constraints aren’t detectable, ask for target WP core and PHP versions. diff --git a/.agents/skills/wp-abilities-api/SKILL.md b/.agents/skills/wp-abilities-api/SKILL.md new file mode 100644 index 0000000..d43cccf --- /dev/null +++ b/.agents/skills/wp-abilities-api/SKILL.md @@ -0,0 +1,107 @@ +--- +name: wp-abilities-api +description: "Use when working with the WordPress Abilities API (wp_register_ability, wp_register_ability_category, /wp-json/wp-abilities/v1/*, @wordpress/abilities) including defining abilities, categories, meta, REST exposure, and permissions checks for clients." +compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+). Filesystem-based agent with bash + node. Some workflows require WP-CLI." +--- + +# WP Abilities API + +## When to use + +Use this skill when the task involves: + +- registering abilities or ability categories in PHP, +- exposing abilities to clients via REST (`wp-abilities/v1`), +- consuming abilities in JS (notably `@wordpress/abilities`), +- diagnosing “ability doesn’t show up” / “client can’t see ability” / “REST returns empty”. + +## Inputs required + +- Repo root (run `wp-project-triage` first if you haven’t). +- Target WordPress version(s) and whether this is WP core or a plugin/theme. +- Where the change should live (plugin vs theme vs mu-plugin). + +## Procedure + +Before deciding what to register, read `references/domain-vs-projection.md` — abilities live at the domain capability layer; MCP / Command Palette / REST exposure is a projection. Registration shape and exposure shape are different decisions, and conflating them forces re-registration every time a consumer's constraints change. + +### 1) Confirm availability and version constraints + +- If this is WP core work, check `signals.isWpCoreCheckout` and `versions.wordpress.core`. +- If the project targets WP < 6.9, you may need the Abilities API plugin/package rather than relying on core. + +### 2) Find existing Abilities usage + +Search for these in the repo: + +- `wp_register_ability(` +- `wp_register_ability_category(` +- `wp_abilities_api_init` +- `wp_abilities_api_categories_init` +- `wp-abilities/v1` +- `@wordpress/abilities` + +If none exist, decide whether you’re introducing Abilities API fresh (new registrations + client consumption) or only consuming. + +### 3) Register categories (optional) + +If you need a logical grouping, register an ability category early (see `references/php-registration.md`). + +### 4) Register abilities (PHP) + +For grouping decisions (how many abilities to register, and where to put filters vs. new ability names), read `references/grouping-heuristic.md` first — it keeps you from shipping one atomic ability per REST operation. + +To avoid drift between the ability and the existing UI / REST code path, see `references/shared-core-service.md` — abilities, REST handlers, CLI commands, and UI controllers should be thin adapters over a shared service. The reference also covers the metric trap (REST handlers that emit usage telemetry) and the `AGENTS.md` rule for keeping registrations in sync when underlying code paths change. + +For shared helper patterns when multiple execute callbacks delegate to existing REST controllers, see `references/plugin-family-patterns.md` (identify the shared-API-client vs zero-arg-controllers shape) and `references/delegate-helper-pattern.md` (one helper shape that works, and when not to use it). + +For standardized `WP_Error` codes that let agents reason about retry vs. escalation, see `references/error-code-vocabulary.md`. + +Implement the ability in PHP registration with: + +- stable `id` (namespaced), +- `label`/`description`, +- `category`, +- `meta`: + - add `readonly: true` when the ability is informational, + - set `show_in_rest: true` for abilities you want visible to clients. + +Use the documented init hooks for Abilities API registration so they load at the right time (see `references/php-registration.md`). + +### 5) Confirm REST exposure + +- Verify the REST endpoints exist and return expected results (see `references/rest-api.md`). +- If the client still can’t see the ability, confirm `meta.show_in_rest` is enabled and you’re querying the right endpoint. + +### 6) Consume from JS (if needed) + +- Prefer `@wordpress/abilities` APIs for client-side access and checks. +- Ensure build tooling includes the dependency and the project’s build pipeline bundles it. + +## Verification + +- `wp-project-triage` indicates `signals.usesAbilitiesApi: true` after your change (if applicable). +- REST check (in a WP environment): endpoints under `wp-abilities/v1` return your ability and category when expected. +- If the repo has tests, add/update coverage near: + - PHP: ability registration and meta exposure + - JS: ability consumption and UI gating + +## Failure modes / debugging + +- Ability never appears: + - registration code not running (wrong hook / file not loaded), + - missing `meta.show_in_rest`, + - incorrect category/ID mismatch. +- REST shows ability but JS doesn’t: + - wrong REST base/namespace, + - JS dependency not bundled, + - caching (object/page caches) masking changes. +- Execute callback returns unexpected errors or silently ignores input: + - `input_schema` defaults aren't being applied, pagination key drift between the ability and the backing, or `empty()`-based ID validation — see `references/input-schema-gotchas.md`. + +## Escalation + +- If you’re uncertain about version support, confirm target WP core versions and whether Abilities API is expected from core or as a plugin. +- For canonical details, consult: + - `references/rest-api.md` + - `references/php-registration.md` diff --git a/.agents/skills/wp-abilities-api/references/delegate-helper-pattern.md b/.agents/skills/wp-abilities-api/references/delegate-helper-pattern.md new file mode 100644 index 0000000..9aa4346 --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/delegate-helper-pattern.md @@ -0,0 +1,241 @@ +# Delegate helper pattern + +Once you've decided delegation is the right shape for an ability — that is, the backing REST controller is a pure data-fetch, the operation is a read, and the ability runs predominantly inside REST contexts (see `shared-core-service.md` for when the answer is "no, extract a shared service" instead) — extract a `delegate_to_rest_controller` helper rather than open-coding the request-build/dispatch/unwrap pipeline in each execute callback. This reference documents one helper shape that works, the three guards every implementation needs, the dual response-shape unwrap, and when NOT to use the helper at all. Adapt the exact signature to the plugin you're working in — the guards and unwrap matter more than the parameter list. + +Read `plugin-family-patterns.md` first — the helper's exact shape depends on how the backing controller is constructed (shared API client vs. zero-arg). + +## Why this helper exists + +List-style read abilities typically repeat the same four steps in every execute callback: + +1. Validate the plugin is initialized (class exists + dependencies resolved). +2. Build a `WP_REST_Request` and copy the ability's `$input` onto it as params. +3. Instantiate the backing controller and invoke the named method. +4. Unwrap the response (controllers return either a raw array or a `WP_REST_Response`). + +Four abilities × this repetition = the boilerplate dominates the callback body. Extracting a helper keeps each execute callback a 3–4 line function that reads like pseudocode. + +## When to extract + +After the **second** list-style ability lands. Before the third ability gets added, refactor the duplicated code out of the first two execute callbacks into a private static helper. Convert subsequent abilities to call the helper as you add them. + +If the plugin only ever ships one or two abilities, inline the code. The helper's payoff is at 3+ callers. + +## Helper shape + +```php +/** + * Delegate an ability's execute callback to a REST controller. + * + * Builds a WP_REST_Request from the ability's input, instantiates the + * backing controller with the plugin's shared API client, invokes the + * named method, and normalizes the return so callers always see + * array|\WP_Error. Controllers in this plugin return either a + * WP_REST_Response or a raw array; the helper unwraps both shapes. + * + * Not used by zero-arg abilities — those call their controller directly + * so we don't synthesize a WP_REST_Request just to discard it. + * + * @param string $controller_class Fully-qualified controller class (no leading backslash). + * @param string $method Controller method to invoke on the built request. + * @param string $route REST route string used when constructing WP_REST_Request. + * @param array|null $input Ability input; each key/value becomes a request param. + * @param string $http_method HTTP method for WP_REST_Request. Defaults to 'GET'; writes pass 'POST'. + * @return array|\WP_Error Response payload as an array, or WP_Error on failure. + */ +private static function delegate_to_rest_controller( + $controller_class, + $method, + $route, + $input, + $http_method = 'GET' +) { + $fqcn = '\\' . $controller_class; + + // Guard 1: controller class is loaded. + if ( ! class_exists( $fqcn ) ) { + return new \WP_Error( + '_not_initialized', + __( ' is not initialized.', '' ) + ); + } + + // Guard 2: shared API client is available. + $api_client = null; + if ( class_exists( '\' ) && method_exists( '\', 'get_' ) ) { + $api_client = \::get_(); + } + if ( null === $api_client ) { + return new \WP_Error( + '_not_initialized', + __( ' is not initialized.', '' ) + ); + } + + // Build the request. + $request = new \WP_REST_Request( $http_method, $route ); + if ( null !== $input ) { + foreach ( $input as $param => $value ) { + $request->set_param( $param, $value ); + } + } + + // Invoke + guard 3: WP_Error short-circuit + dual-shape unwrap. + $controller = new $fqcn( $api_client ); + $response = $controller->{$method}( $request ); + + if ( is_wp_error( $response ) ) { + return $response; + } + if ( $response instanceof \WP_REST_Response ) { + $data = $response->get_data(); + return is_array( $data ) ? $data : []; + } + return is_array( $response ) ? $response : []; +} +``` + +Positional (not named) arguments on purpose — the helper is called from every list ability and keyword-args for PHP 8 would add visual noise. Order is intentional: controller, method, route, input, http_method (the defaultable tail). + +## The three guards + +### 1. `class_exists` on the controller + +Execute callbacks can be reached on sites where the plugin's classes haven't loaded (tests, WP-CLI with limited bootstrap, admin pages with conditional autoloading). Check is cheap; without it a missing class produces a fatal. + +Note `'\\' . $controller_class` — accept controller class names without a leading backslash (readable in config arrays) and re-add it so `class_exists` and `new $fqcn(...)` both root-resolve. + +### 2. Required dependency (API client) null-check + +When the controller takes a shared API client as a constructor argument, the accessor is typically a `public static` method on the main plugin class. Guard both `class_exists` on the plugin class AND `method_exists` on the accessor because either could be absent during partial bootstraps. Treat a null return from the accessor as "not initialized". + +Missing this argument produces `ArgumentCountError: Too few arguments to function ::__construct(), 0 passed` — a PHP fatal. The standardized `_not_initialized` error code is documented in `error-code-vocabulary.md`. + +When the controller takes no constructor args, skip this guard entirely. When it takes only simple scalars (e.g. a post-type string), pass them in via an optional `constructor_args` parameter on the helper. + +### 3. `is_wp_error` short-circuit + +Before trying to unwrap the response, check if it's already a `WP_Error`. Several controllers return `WP_Error` for both validation failures and upstream failures; that error needs to bubble up intact so the agent can see the upstream code. + +## Dual response-shape unwrap + +```php +if ( $response instanceof \WP_REST_Response ) { + $data = $response->get_data(); + return is_array( $data ) ? $data : []; +} +return is_array( $response ) ? $response : []; +``` + +Real WordPress REST controllers return either: +- A `WP_REST_Response` (the output of `rest_ensure_response( ... )` or `new WP_REST_Response( ... )`), or +- A raw array (typical when a controller delegates to an internal request-handler class that returns the decoded transport payload directly). + +Both happen. The helper unwraps `WP_REST_Response` via `get_data()` and passes raw arrays through. The `is_array(...) ? ... : []` coalesce defends against a non-array return type that the ability's `output_schema` would have rejected downstream anyway — returning `[]` fails safely rather than leaking a surprising type. + +## When NOT to use the helper + +Three categories of abilities bypass the helper and call the backing directly: + +### Backing request class can be invoked without the controller + +If the REST controller's only role is to translate `WP_REST_Request` into a call to an underlying request class (e.g., `Things_Request::from_rest_request( $request )->execute()`) and adds no validation, permission logic, or orchestration on top, bypass the controller and call the request class directly: + +```php +public static function execute_get_things( $input = null ) { + if ( ! class_exists( '\My_Plugin\Things_Request' ) ) { + return new \WP_Error( '_not_initialized', __( ' is not initialized.', '' ) ); + } + + $request = new \WP_REST_Request( 'GET', '/my-plugin/v1/things' ); + foreach ( (array) $input as $param => $value ) { + $request->set_param( $param, $value ); + } + + $things_request = \My_Plugin\Things_Request::from_rest_request( $request ); + $rows = $things_request->execute(); + + return is_array( $rows ) ? $rows : []; +} +``` + +The `WP_REST_Request` object exists only to satisfy `from_rest_request()`'s parameter contract — nothing dispatches it. This is the shortest path through the layers: no controller construction, no controller-emitted side effects, and (when the request class is hit before any REST traffic in the lifecycle) no `rest_api_init` cost. See `shared-core-service.md` for the broader discussion of REST-path side effects and the first-call bootstrap cost on cold paths. + +The tradeoff is real: bypassing the controller bypasses anything the controller does. If the controller runs validation that the request class doesn't, or emits an audit hook the request class doesn't, that work is lost on the ability path. Use this shape when the controller is genuinely a thin wrapper, not when it's doing work you'd want to keep. + +### Zero-arg backing methods + +If the backing method takes no `WP_REST_Request` parameter, don't synthesize a request just to discard it. Call the method directly: + +```php +public static function execute_get_overview( $input = null ) { + if ( ! class_exists( '\My_Plugin_REST_Overview_Controller' ) ) { + return new \WP_Error( '_not_initialized', __( ' is not initialized.', '' ) ); + } + + $api_client = /* ... same accessor check ... */; + if ( null === $api_client ) { + return new \WP_Error( '_not_initialized', __( ' is not initialized.', '' ) ); + } + + $controller = new \My_Plugin_REST_Overview_Controller( $api_client ); + $response = $controller->get_overview(); // No $request argument. + + if ( is_wp_error( $response ) ) { + return $response; + } + return is_array( $response ) ? $response : []; +} +``` + +### Abilities that use a non-REST service + +If the execute callback reaches a service directly (e.g. `My_Plugin::get_account_service()->get_cached_account_data()`) rather than a REST controller, stay on the direct path. The ability is not REST-backed and the helper would add a construction step for no gain. + +## Rule of thumb + +**If the backing method takes `WP_REST_Request` and the controller adds something beyond delegating to an underlying request class (validation, permissions, orchestration, side effects you want to keep), use the helper. If the controller is a thin wrapper over a request class, call the request class directly. Otherwise direct-path.** + +## Conversion pattern — before and after + +Before extraction (inline in the execute callback): + +```php +public static function execute_get_things( $input = null ) { + if ( ! class_exists( '\My_Plugin_REST_Things_Controller' ) ) { + return new \WP_Error( '_not_initialized', __( ' is not initialized.', '' ) ); + } + $api_client = \My_Plugin::get_api_client(); + if ( ! $api_client ) { + return new \WP_Error( '_not_initialized', __( ' is not initialized.', '' ) ); + } + $request = new \WP_REST_Request( 'GET', '/my-plugin/v1/things' ); + foreach ( (array) $input as $param => $value ) { + $request->set_param( $param, $value ); + } + $controller = new \My_Plugin_REST_Things_Controller( $api_client ); + $response = $controller->get_things( $request ); + // ... unwrap ... +} +``` + +After extraction: + +```php +public static function execute_get_things( $input = null ) { + return self::delegate_to_rest_controller( + 'My_Plugin_REST_Things_Controller', + 'get_things', + '/my-plugin/v1/things', + is_array( $input ) ? $input : null + ); +} +``` + +Note the `is_array( $input ) ? $input : null` — the helper signature is `?array`, and passing `null` tells it to build the request with no params at all. This matters because the Abilities API sometimes invokes a callback with no input object. + +## Related references + +- `plugin-family-patterns.md` — choosing the helper's constructor shape. +- `error-code-vocabulary.md` — the `_not_initialized` code and its siblings. +- `input-schema-gotchas.md` — callbacks for list abilities often need `per_page` pagination translation BEFORE calling the helper. diff --git a/.agents/skills/wp-abilities-api/references/domain-vs-projection.md b/.agents/skills/wp-abilities-api/references/domain-vs-projection.md new file mode 100644 index 0000000..ffad35d --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/domain-vs-projection.md @@ -0,0 +1,113 @@ +# Domain capability vs projection — picking the layer to register at + +> Three layers in this model: **domain**, **projection**, and an optional **workflow** layer that composes abilities into multi-step tasks. The title names the two primary decisions; workflow is introduced where it earns its keep. + +The Abilities API can be used at two heights. You can register abilities as the surface an MCP/REST/Command-Palette client will consume directly. Or you can register abilities at the domain layer — "what can this plugin do, transport-neutrally?" — and let each consumer see a projection of that surface that suits its constraints. This reference covers why the second framing pays off, the three-layer model that operationalizes it, and how to use it when deciding what to register. + +## Why this matters + +Treating MCP exposure as the registration target conflates two decisions: + +1. What capabilities does this plugin offer? +2. How should each consumer see them? + +The first decision is stable. The second is volatile — token budgets shift, MCP clients improve, Command Palette gains new conventions, large plugins outgrow flat tool lists and adopt nested-discovery patterns. Coupling the two means re-registering abilities every time the projection question is reopened. + +Decoupling them means registrations stay where they are while projections evolve underneath. + +## The three layers + +| Layer | Purpose | Examples | +|---|---|---| +| **Domain capability** | Stable, transport-neutral, permission-checked. The plugin's contract for "what can this do." | `myplugin/list-things`, `myplugin/update-thing`, `myplugin/get-overview` | +| **Workflow** *(optional)* | Compositions of one or more abilities into a human- or agent-friendly task. | "Onboard a new merchant" = `verify-account` → `enable-feature` → `send-welcome` | +| **Projection** | Consumer-specific view of the domain layer. Token-efficient compression for MCP, curated workflows for Command Palette, discoverable execution surface for REST, forms for admin UI, chainable commands for CLI. | The bundled WordPress MCP adapter projects every published ability through three meta-abilities (`mcp-adapter/discover-abilities`, `mcp-adapter/execute-ability`, `mcp-adapter/get-ability-info`) — agents traverse those three rather than seeing the full registry flat. Command Palette, by contrast, can show the same domain abilities flat because token budget is not a constraint. | + +The domain layer is what the registry holds. The projection layer is what each consumer sees. The workflow layer is optional and exists when a user-or-agent-meaningful task needs more than one ability. + +## The use-case-contract test + +A registered ability is a use-case contract — a natural-language shortcut to an action a human can already perform in the UI. REST endpoints and CLI commands are transport contracts; they expose plumbing. Abilities expose actions. + +Two consequences fall out of that framing: + +### 1. Inclusion test — "would a human intentionally do this through a supported UI or workflow?" + +If yes, the operation is a candidate for an ability. The lens is broader than wp-admin alone: a "supported UI or workflow" covers admin screens, public-facing UIs (storefront, account dashboard, course viewer, appointment booker), end-user self-service flows on the site front-end, and supported workflows in which another plugin or an agent calls the operation as part of a chain of actions. Abilities like `store/get-my-orders`, `events/list-available-tickets`, or `profile/update-public-profile` qualify just as much as admin-side abilities like `myplugin/list-pending-orders` or `myplugin/approve-submission`. + +If no, the operation stays a REST endpoint, a CLI command, or an internal hook. Internal-only plumbing — cache invalidation, scheduler ticks, debug snapshots, lifecycle bookkeeping — does not belong as an ability even when it has a clean schema. There is no meaningful human invocation point, so there is no use-case contract to register. + +The test is asymmetric: not everything a UI exposes should be an ability either (rule 1 in `grouping-heuristic.md` covers grouping). But anything that has no human-meaningful invocation surface almost certainly is not ability-meaningful. + +### 2. Same code path as the UI + +If the ability mirrors a UI action, it must share the UI's permissions, validation, and business rules. The mechanism for keeping that promise is in `shared-core-service.md`. The framing is here: the ability is the use-case; UI / REST / CLI / MCP are thin adapters over it. + +## Implications + +### You may register abilities you do NOT expose via MCP + +The Abilities API is a WordPress core primitive. It establishes a formal contract for "run this code with these inputs under this permission check." MCP, REST, and Command Palette are exposure layers on top. You opt in. + +This means a plugin can register abilities that the Command Palette uses but MCP does not see, or vice versa. The registration is transport-neutral; exposure is per-consumer. + +### The same domain ability can appear in multiple projections with different shapes + +A small plugin might expose its three abilities flat in MCP and flat in Command Palette. A larger plugin might keep the same three domain abilities but project them through a nested-discovery facade for MCP (three meta-tools that traverse to the underlying registrations) while showing them flat in the Command Palette where token budget is not a constraint. + +The domain layer does not change. The projection layer does the consumer-specific work. + +### Token-efficiency tradeoffs live at the projection layer + +The choice between flat-with-full-schemas, semantic-grouping, single-tool-facade, and nested-discovery is a *projection* decision. Different shapes serve different consumer constraints. None of those shapes require changing what abilities are registered at the domain layer; they change how a consumer sees the registered set. + +This is what "pattern-agnostic" means in practice: the registration is one decision, the projection is another, and a redesign of the second does not invalidate the first. + +## Decision order + +When designing a plugin's ability surface, work in this order: + +1. **Domain first.** What can this plugin do, transport-neutrally? Use the inclusion test. Apply `grouping-heuristic.md` to pick the right granularity (semantic-intent over REST-atomization). +2. **Projection second.** Which surfaces does each capability appear on, and in what shape? For most plugins under ~10 abilities, flat-in-every-projection works. For larger plugins, consider nested-discovery for MCP while keeping flat for Command Palette and REST. +3. **Workflow third (only if needed).** Are there multi-ability tasks worth composing into a single user-or-agent-facing entry point? If yes, the workflow lives above the domain layer and references multiple abilities. + +Most plugins never need step 3. + +## Worked example — generic Notifications plugin + +A "Notifications" plugin manages a per-user notification feed. It supports listing, marking read, and dismissing all. + +### Domain layer + +Three abilities, registered once: + +- `notifications/list` — read; filter by `unread_only`, `since`, `category`. +- `notifications/mark-read` — write; takes a `notification_id`. +- `notifications/dismiss-all` — write; zero-arg. + +These are use-case contracts. They are transport-neutral. They do not assume MCP or Command Palette or REST. + +### Projection — MCP + +Small surface, flat works. The MCP projection exposes the three abilities as-is. No nested-discovery needed. + +### Projection — Command Palette + +The Command Palette projection adds a small workflow that wraps `notifications/list` with default filters (`unread_only=true`) and opens the admin notifications page on selection. The other two abilities remain visible as flat commands. The domain registration did not change; the Command Palette layer added the workflow on top. + +### Projection — REST + +The REST projection exposes only the read ability. Writes go through admin UI for security-review reasons. The domain registration is unchanged; the REST layer just doesn't expose two of the three. + +Same three registrations, three different projections, no re-registration as projection decisions evolve. + +## Escape hatch — tiny plugins + +For plugins with one to three admin-only abilities and no other surfaces, the projection layer is trivially the identity function. You don't need to *implement* layers; you need to *think* in layers so you don't paint yourself into a corner when the plugin grows or when MCP token budgets become a constraint. + +Concretely: name the abilities at the domain level (`myplugin/get-thing`, not `myplugin-mcp/get-thing`), keep the registration permission-checked and transport-neutral, and skip the projection layer until something needs it. + +## Related references + +- `grouping-heuristic.md` — within-domain decisions: how many abilities, where to put filters. +- `shared-core-service.md` — the implementation mechanism for "same code path as the UI." diff --git a/.agents/skills/wp-abilities-api/references/error-code-vocabulary.md b/.agents/skills/wp-abilities-api/references/error-code-vocabulary.md new file mode 100644 index 0000000..3ff2201 --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/error-code-vocabulary.md @@ -0,0 +1,123 @@ +# Error code vocabulary + +Every `WP_Error` returned from an ability's execute callback should use a code drawn from this small vocabulary. A consistent vocabulary lets the agent (or the caller in your automation) build retry and recovery logic that matches on codes instead of parsing error messages. + +## Why standardized codes matter + +Agents that orchestrate multiple abilities need to know: "did this fail because of my input, or because something downstream is unreachable?" The answer drives retry strategy: + +- Bootstrap failures → surface to the human, don't retry. +- Input shape errors → fix the input and retry the same call. +- Transient backend errors → retry with backoff; may succeed on its own. +- Upstream third-party errors → may need a different strategy entirely. + +Without a convention, every plugin invents its own codes and agents can't reason uniformly across them. The categories below cover 95% of real-world ability errors; sticking to them means an agent that learned the retry logic for one plugin can reuse it for the next. + +## Vocabulary + +Substitute `` with the plugin's slug in lowercase, underscores only. This matches WordPress error-code conventions. If the plugin already has a house style — for example, a single-word slug that deliberately omits underscores between elided words — mirror that. Consistency within a plugin trumps consistency across the vocabulary. + +| Code | When to use | Agent behavior | +|---|---|---| +| `_not_initialized` | Plugin class missing, shared service accessor returns null, required dependency isn't resolvable. | Not retriable from the agent side. Usually a config or bootstrap-ordering problem. Escalate. | +| `_missing_` | A required input field is missing or empty (your execute callback's own check, not the schema-validator's). | Agent should add the field and retry. | +| `_invalid_` | Field is present but semantically wrong (bad enum value, malformed date, wrong type). | Agent should correct the value and retry. | +| `__data_unavailable` | Backing service returned a transient error (cache miss + remote failure, upstream 5xx, stale-while-revalidate failed). | Agent can retry, probably with backoff. | +| `ability_invalid_input` | The Abilities API's own schema-validator rejected the input before the execute callback ran. | Equivalent to `_missing_` or `_invalid_`, just thrown earlier in the pipeline. Agent should fix input. | + +### `ability_invalid_input` — the earlier path + +When an ability is invoked via the Abilities API REST bridge, the registered `input_schema` runs first. Missing required fields or type mismatches produce `WP_Error( 'ability_invalid_input' )` BEFORE the execute callback fires. Direct invocation (unit tests, non-REST wrappers) bypasses schema validation and hits your execute callback's own checks instead, producing `_missing_` / `_invalid_`. + +Both paths are acceptable — end-agent-facing behavior is equivalent (deterministic validation error, no side effects). Document the observation in the ability's PR description or "notes for reviewers" so reviewers know both codes are expected in different harness runs. + +## Worked examples + +```php +// Bootstrap incomplete — plugin class missing or accessor returned null. +return new \WP_Error( + '_not_initialized', + __( ' is not initialized.', '' ) +); + +// Required field missing (execute-callback check, schema was bypassed). +return new \WP_Error( + '_missing_', + __( 'A is required to .', '' ) +); + +// Field present but malformed. +return new \WP_Error( + '_invalid_', + sprintf( + /* translators: %s: input parameter name. */ + __( 'The "%s" parameter must be an ISO 8601 date-time string.', '' ), + $key + ) +); + +// Transient backend error. +return new \WP_Error( + '__data_unavailable', + __( 'Unable to retrieve data. The cache may be stale or the remote service returned an error.', '' ) +); +``` + +## Upstream codes can bubble through — and usually should + +If the backing controller talks to a third-party API (Stripe, WPCOM, another SaaS), its own error codes may surface verbatim in `WP_Error::get_error_code()` the ability returns. Typical examples: + +- `resource_missing` — Stripe's "object ID not found" code. Tells an agent the specific ID was wrong. +- `rate_limited` — upstream throttling. Tells an agent to slow down. + +**Let these through rather than re-wrapping.** A re-wrapped `__not_found` loses the information that would help the agent act. Document the upstream codes you've observed in the ability's `description` or PR notes so reviewers know they're expected. + +## Code naming rules + +1. **Lowercase, underscores only.** `_missing_order_id`, not `-missingOrderId` or `-MissingOrderId`. +2. **Plugin prefix first.** Multi-word slugs should normalize to a single-word prefix where the plugin already does (e.g. `woopayments` rather than `woo_payments`). +3. **Action second.** Use one of `missing`, `invalid`, `not_initialized`, `_data_unavailable`. +4. **Field or resource name last.** `_missing_dispute_id`, not `_dispute_id_missing`. This matches English phrasing ("a dispute_id is required") and is faster to skim in error logs. + +## Internationalization + +Error MESSAGES are translatable via `__()` or `sprintf(__())`. Error CODES are not — they're stable machine identifiers. + +```php +// WRONG — don't translate the code. +return new \WP_Error( __( '_missing_order_id', '' ), ... ); + +// RIGHT — code is a literal, message is translatable. +return new \WP_Error( + '_missing_order_id', + __( 'An order_id is required.', '' ) +); +``` + +When a message interpolates a parameter name or value, include a translator comment: + +```php +return new \WP_Error( + '_invalid_date', + sprintf( + /* translators: %s: input parameter name. */ + __( 'The "%s" parameter must be an ISO 8601 date-time string.', '' ), + $key + ) +); +``` + +## Don't over-specify + +The goal is consistent codes across all of a plugin's abilities, not ultra-fine granularity. `_missing_dispute_id` is the right level. `_missing_dispute_id_in_submit_evidence_context` is not — the ability name belongs in `WP_Error::get_error_data()` or in the agent's calling context, not in the code. + +## Summary — the four questions + +When writing an execute callback, ask in order: + +1. Can the plugin even service this call? → `_not_initialized`. +2. Is the required input present? → `_missing_`. +3. Is the required input the right shape? → `_invalid_`. +4. Did the backing service choke? → `__data_unavailable`, or let the upstream code bubble through. + +Four categories, one consistent code vocabulary per plugin. That's enough for agents to build reliable retry logic on top. diff --git a/.agents/skills/wp-abilities-api/references/grouping-heuristic.md b/.agents/skills/wp-abilities-api/references/grouping-heuristic.md new file mode 100644 index 0000000..810a17e --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/grouping-heuristic.md @@ -0,0 +1,89 @@ +# Grouping heuristic — domain-layer granularity + +How to decide WHAT to register when a plugin already has a REST (or internal service) surface. The hard part of adopting the Abilities API is not the registration syntax — it's picking the right *domain-layer granularity* so abilities map to user-meaningful actions instead of HTTP plumbing. + +> **Scope note.** This reference governs *domain-layer* decisions: how many abilities to register, where to put filters vs. where to introduce a new ability name. It does NOT govern projection-layer choices (flat-with-full-schemas vs single-tool facade vs nested-discovery vs semantic grouping in the consumer view). Those are separate decisions made *after* the domain layer is settled — see `domain-vs-projection.md` for the layering. A domain layer chosen well is reusable across multiple projections; conflating the two means re-registering every time a consumer's constraints change. + +## Three observed approaches + +| Approach | Shape | Example | Verdict (domain-layer) | +|---|---|---|---| +| **Action-bundle** | One ability bundles many sub-operations behind an action string. | `my_plugin_account` with `action: "get" \| "update" \| "delete"`. | **Avoid.** Hides the ability surface from the agent's tool-list and defeats the Abilities API's introspection model — agents can't see what a bundle can do until they invoke it. | +| **REST-atomization** | One ability per HTTP method per resource (typically 5 per resource: list, get, create, update, delete). | `orders-list`, `orders-get`, `orders-create`, `orders-update`, `orders-delete`. | **Avoid as the registration shape.** Couples ability names to HTTP plumbing rather than user intent — and forces re-registration if the projection layer ever needs to compress the surface. | +| **Semantic-intent** | One ability per real-world question or state transition. Filter parameters in `input_schema` collapse N variants into 1. | One `feedback/get-responses` ability with `status`, `is_unread`, `search`, and date-range filters in `input_schema` — replaces what would be 8+ atomized variants. | **Recommended for the domain layer.** | + +## Why semantic-intent wins at the domain layer + +1. **Users think in questions, not HTTP verbs.** "Which form responses are unread?" maps cleanly to one ability with an `is_unread` filter. It does NOT map to 8 abilities (`get-unread-responses`, `get-spam-responses`, `get-trashed-responses`, ...). Ability names are *use-case contracts* — see `./domain-vs-projection.md`. +2. **The Abilities API's `input_schema` is designed for rich inputs.** Enum constraints, date-time formats, and required-field validation do the variant-splitting job that atomization would delegate to the ability name. +3. **Writes stay narrow anyway.** A write ability should already be one state transition; atomization and semantic-intent converge for writes. +4. **Tool-list token cost is a downstream consequence, not the reason.** Semantic-intent registrations also serialize cheaper in flat MCP projections — but token cost is a *projection-layer* concern. If registrations are cheap by accident because the use-case framing happened to compress them, that's a happy side-effect; if they're expensive, the fix is at the projection layer (single-tool facade, nested-discovery), not by re-grouping the domain. + +## Rules that make it work + +### 1. Group reads by the question a user would type + +Draft the question in plain English. That question is the ability. The filter parameters go in `input_schema`. + +- WRONG: one ability per status value. +- RIGHT: one `get-` ability with `status: { type: "string", enum: [...] }`. + +### 2. Keep writes narrow — one state transition per ability + +A write ability should do exactly one thing the agent can reason about in isolation and explain to a user. Different state transitions → different abilities (different consequences, different annotations, different permission implications). + +- WRONG: `update-resource` that branches internally on an action enum. +- RIGHT: `submit-evidence` and `close-resource` as separate abilities. + +### 3. Prefer 1 ability with filter params over N abilities with no params + +Ask: "if the backing added a new filter value, would that create a new ability?" If not, the filter belongs in `input_schema`, not in the ability name. + +### 4. Zero-arg overview abilities are high-leverage + +When enumerating the backing surface, specifically look for zero-argument aggregate or "overview" endpoints — "what's my balance?", "what's my next payout?", "what's my form response count?". These answer the highest-frequency user questions with zero input and zero room for agent error. Flag them even if they weren't in the original plan. + +### 5. Don't ship abilities you can't explain in one sentence + +Every ability's `label` + `description` should fit in an agent's tool-selection prompt. If you can't describe the ability in one sentence without "and", that's usually a sign it's two abilities. + +## Worked example A — feedback/responses: 3 abilities for the whole responses surface + +Consider a generic feedback or form-response plugin. Its admin screens expose: a list with 8+ filters, a detail view, bulk status changes (spam / trash / publish), read/unread toggles, and a count-by-status dashboard summary. + +REST-atomization would ship ~6 abilities (list, get, delete, update, bulk-update, count). Semantic-intent registers **three**: + +- `feedback/get-responses` — list/search, with `status`, `is_unread`, `search`, `before`, `after`, `parent` in `input_schema`. +- `feedback/update-response` — one write that covers status changes AND read-state toggles on a single response (semantically "modify a response"). +- `feedback/get-status-counts` — the dashboard summary ability. Zero-arg-friendly (only optional filters). + +Why three works: a user asking "show me spam responses from last week" uses one ability. An agent updating one response to `spam` uses one ability. The dashboard-style "how many unread?" uses one ability. The entire product surface fits in three tool-list entries. + +## Worked example B — generic Tickets plugin: one ability with a status filter, not eight + +A hypothetical `myplugin-tickets` plugin manages a support-ticket queue. Its REST endpoint `GET /myplugin/v1/tickets` accepts `status`, `priority`, `assigned_to`, `tag`, `date_before`, `date_after`, `search`. Status values include `new`, `triaged`, `in_progress`, `waiting_customer`, `waiting_internal`, `resolved`, `closed`, `reopened` — eight in total. + +- Atomization would ship ~8 abilities (`get-tickets-new`, `get-tickets-resolved`, `get-tickets-closed`, ...). +- Semantic-intent ships **one** — `myplugin-tickets/get-tickets` with `status: { type: "string", enum: ["new", "triaged", "in_progress", "waiting_customer", "waiting_internal", "resolved", "closed", "reopened"] }`. + +The user question "which tickets are waiting on the customer?" becomes one ability invocation with `status: "waiting_customer"`. The agent doesn't scan a list of 8 near-identical tool names; it scans one, and the enum documents what values are valid. + +The same plugin also registers a zero-arg `myplugin-tickets/get-queue-summary` ability (open count + average response time + oldest unresolved) because "how is the queue today?" is the highest-frequency support-team question and the backing endpoint takes no arguments. That one ability has outsized value for one line of registration code — rule 4 in action. + +## Escape hatch — when per-operation granularity is right (for reads) + +Two cases where one-ability-per-operation IS appropriate on the read +side, despite the recommendation against REST-atomization for reads: + +1. **Genuinely different permission models.** If `list-` and `search-` require different capabilities or different confirmation flows, splitting is honest. +2. **Different destructive / idempotent annotations.** An ability that both reads and writes cannot honestly declare `readonly: true`; split the read-only part into its own ability. + +For writes, this escape hatch isn't needed: rule 2 ("one state +transition per ability") already establishes per-operation granularity +as the default. Splitting `submit-evidence` and `close-resource` into +separate abilities isn't an exception — it's rule 2 in action. + +## Related references + +- `domain-vs-projection.md` — granularity governs *domain-layer* decisions; that reference covers the projection layer where token-efficiency tradeoffs and consumer-shape choices live. +- `shared-core-service.md` — implementation mechanism for keeping abilities, REST handlers, CLI commands, and UI in lockstep on the domain layer. diff --git a/.agents/skills/wp-abilities-api/references/input-schema-gotchas.md b/.agents/skills/wp-abilities-api/references/input-schema-gotchas.md new file mode 100644 index 0000000..481d1a4 --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/input-schema-gotchas.md @@ -0,0 +1,265 @@ +# Input schema gotchas + +Three surprises the Abilities API's `input_schema` will ship with if you rely on schema declarations alone. All three have been caught in real plugin work after the schema looked correct and tests passed; each has a defensive pattern that makes the execute callback robust regardless. + +## 1. Schema `default` values are NOT injected into execute callback input + +### Problem + +`wp_register_ability()` lets you declare per-property defaults in `input_schema`: + +```php +'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'submit' => [ + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Whether to submit evidence for review.', '' ), + ], + ], +], +``` + +The Abilities API's validate path enforces `type` and `required`, but it does NOT populate missing properties with their declared `default`. If an agent invokes the ability with `{ dispute_id: "dp_..." }` and omits `submit`, the execute callback receives `$input` **without** a `submit` key — it does not get `$input['submit'] = false`. + +### Symptoms + +- Boolean defaults silently become "undefined" in the callback and any `if ( $input['submit'] )` check compares against `null`, which works by accident but fails `isset` checks and strict-type branches. +- Integer pagination defaults like `'page' => [ 'default' => 1 ]` never reach the backing controller, so pagination falls back to the controller's internal default rather than the one declared in the schema. +- Object defaults (`'metadata' => [ 'default' => [] ]`) become `null` rather than `[]` and any `foreach ( $input['metadata'] as ... )` hits a type error. + +### Fix — normalize defaults explicitly in the execute callback + +```php +public static function execute_submit_evidence( $input = null ) { + if ( ! is_array( $input ) ) { + $input = []; + } + + // Apply schema defaults the Abilities API does not inject. + if ( ! array_key_exists( 'submit', $input ) || null === $input['submit'] ) { + $input['submit'] = false; + } + $input['submit'] = (bool) $input['submit']; + + if ( ! array_key_exists( 'metadata', $input ) || null === $input['metadata'] ) { + $input['metadata'] = []; + } + + // ... rest of callback +} +``` + +The `array_key_exists` + null-check pattern catches both "missing key" and "explicit null" (some serializers produce nulls for optional fields). + +Keep the declared `default` in `input_schema` anyway — it documents the expected behavior for anyone reading the registration and is visible to agents in the schema introspection endpoints. Just don't rely on it for runtime population. + +## 2. Pagination parameter-name drift + +### Problem + +The `input_schema` convention across most WordPress REST endpoints is `per_page` (matching WP core REST list endpoints and `WP_REST_Controller`'s `get_collection_params()`). Some plugin REST controllers, however, delegate to an internal request-builder class that reads a different key (e.g. `pagesize` or `page_size`), typically for historical reasons. + +If the ability's `input_schema` exposes `per_page` and the backing controller reads `pagesize`, the agent's `per_page: 50` silently never reaches pagination — the value is accepted, forwarded verbatim to the backing, and then ignored. Agents keep getting the default page size and suspect their filter is broken. + +### Symptoms + +- List abilities return the backing controller's default page size regardless of the agent's `per_page` input. +- Integration tests that only check "a list came back" pass; only a test asserting `count( $response['data'] )` catches it. +- Harness runs show the raw response count matching the default (25, 10, etc.) for every call. + +### Detection heuristic + +Before shipping a paginated ability, grep the backing controller's call chain: + +```bash +# Check the backing controller and anything it delegates to. +grep -rn "pagesize\|page_size" +``` + +If `pagesize` (or any non-`per_page` key) appears in the chain and the ability's schema exposes `per_page`, the execute callback MUST translate. + +### Fix — translate before delegating + +```php +/** + * Translate pagination keys for abilities whose backing reads a non-standard key. + * + * Abilities expose `per_page` uniformly for agent-facing consistency; this + * helper rewrites it to whatever key the backing actually reads. + * + * @param array|null $input Ability input (or null). + * @return array|null Input with `per_page` rewritten to `` when present. + */ +private static function translate_pagination_keys( $input ) { + if ( ! is_array( $input ) ) { + return $input; + } + if ( isset( $input['per_page'] ) ) { + $input[''] = $input['per_page']; + unset( $input['per_page'] ); + } + return $input; +} + +public static function execute_get_things( $input = null ) { + $input = self::translate_pagination_keys( is_array( $input ) ? $input : null ); + + return self::delegate_to_rest_controller( + '', + 'get_things', + '/my-plugin/v1/things', + $input + ); +} +``` + +Place the translation BEFORE the delegate call so `per_page` never reaches the backing. + +### When NOT to apply this + +Do not apply blindly. If the backing reads `per_page` directly (most modern WP REST controllers do), adding this translation would silently break pagination by renaming the key to something the backing doesn't understand. + +**Always grep the backing first.** The translation is only correct for a specific backing chain; it's not a general safety net. + +## 3. ID validation must not use `empty()` + +### Problem + +PHP's `empty()` is permissive in ways that bite ID validation: + +```php +empty( '0' ) // true — but "0" is a legal string ID (order ID, row ID, post ID in some code paths) +empty( 0 ) // true — same for integer zero +empty( [] ) // true — expected, but same keyword used for different cases +``` + +An execute callback that guards required IDs with `empty( $input['order_id'] )` will false-reject a legitimate `"0"` and return a "missing" error for input that's actually present. The agent retries with the same input, gets the same error, and the call is stuck. + +### Symptoms + +- Abilities reject specific IDs ending in zero or consisting of zero. +- Unit tests written with non-zero IDs pass; a regression lands the day an agent tries a real zero-ID. +- `WP_Error( '_missing_' )` fires for input the schema validator would have accepted. + +### Fix — three explicit checks + +```php +if ( ! isset( $input['order_id'] ) + || ! is_string( $input['order_id'] ) + || '' === $input['order_id'] +) { + return new \WP_Error( + '_missing_order_id', + __( 'An order_id is required to fetch order detail.', '' ) + ); +} +``` + +Three separate checks, each non-redundant: + +1. `! isset( ... )` — the key is present on the array. +2. `! is_string( ... )` — type is right. (For integer IDs, use `is_int` + non-negative check instead.) Without this, a caller that passes `123` (integer) where the schema documents a string would fall through to a later step that does `rawurlencode( $input['order_id'] )` and produces a cryptic coercion error rather than a clean missing-field response. +3. `'' === $input[ ... ]` — non-empty in the strict sense. Rejects empty string only; accepts `"0"` as a legal value. + +Use the standardized `_missing_` error code (see `error-code-vocabulary.md`) for this case. + +### Add a regression-guard unit test + +The guard is load-bearing enough that it deserves an explicit test — otherwise someone will simplify it back to `empty()` in a future refactor: + +```php +public function test_execute_returns_wp_error_when_id_not_a_string() { + $result = My_Plugin_Abilities::execute_get_thing( [ 'order_id' => 123 ] ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( '_missing_order_id', $result->get_error_code() ); +} +``` + +The integer `123` is a fine canary — it's non-empty, it `isset`s, but it's not a string. A callback that only uses `isset` + `empty` would false-pass this input and fall through to the URL construction with an integer argument. + +## 4. Direct vs indirect invocation differ in strictness + +### Problem + +Two ways exist to invoke an ability's `execute_callback`: + +- **Direct.** A caller imports the registrar and calls the static method (`Abilities_Registrar::execute_get_things( $input )`). PHP signature defaults apply; no schema validation runs. +- **Indirect.** A caller resolves the ability through `wp_get_ability( '' )->execute( $input )`. WordPress runs `WP_Ability::normalize_input()` then `validate_input()` (then `check_permissions()`) before the callback is invoked. + +The two paths differ in three ways agents trip over: + +1. **Validation against `input_schema` runs only on the indirect path.** If the ability declares an object-typed schema with properties and the indirect caller passes `null` (or omits the argument entirely, as `$ability->execute()` does), `validate_input()` rejects with an `ability_invalid_input` error before the callback runs. The PHP-level `$input = null` default in the callback signature is dead code on this path. + +2. **Schema's top-level `default` IS applied — but only on the indirect path.** `normalize_input()` substitutes the schema's top-level `default` when the caller passes `null`. Direct callers don't run through this method; they get whatever PHP default the callback signature declares. Declaring `default => (object) array()` at the schema root makes `$ability->execute()` work without arguments — but the same callback called directly still receives PHP `null`. + +3. **The callback's first argument arrives only when `input_schema` is non-empty.** WordPress only forwards `$input` to the callback when the schema is declared. Without an input schema, the callback is invoked with zero arguments — PHP-level signature defaults compensate for this if you wrote them; without them, the indirect path produces an `ArgumentCountError`. + +### Symptoms + +- An ability looks fine when unit-tested by directly invoking the static method, then fails the moment it's exercised through `wp_get_ability(...)->execute()` from MCP / Command Palette / agent harnesses. +- "Works in the test, breaks in MCP" — typical pattern when a test calls `Abilities_Registrar::execute_get_things( [] )` (passing an empty array) but the agent invocation goes through the indirect pipeline. +- A schema with all-optional properties still rejects zero-arg indirect calls because `null` is not an object. + +### Fix — declare a top-level schema `default` for any zero-arg-allowed ability + +```php +'input_schema' => [ + 'type' => 'object', + 'default' => (object) array(), // applied by normalize_input() on null inputs + 'properties' => [ + 'per_page' => [ 'type' => 'integer', 'default' => 10 ], + // ... + ], +], +``` + +`(object) array()` is preferred over `[]` because it json-encodes to `{}` rather than the array literal `[]`, avoiding PHP's array/object ambiguity at the validator boundary. + +### Distinguish from Gotcha 1 + +Top-level schema `default` is honored by `normalize_input()` and reaches the callback. Property-level `default` (Gotcha 1) is NOT — those values are dropped by the validator, and the callback has to defensively reapply them. Different layers, different fates. + +If you've declared the top-level `default` in the schema, the PHP-level signature default exists only for direct callers. Don't add a third layer of fallback inside the callback that re-checks `if ( $input === null )` — three compensating defaults stacked on each other diffuses the meaning of "no input." + +## Putting gotchas 1-3 together + +A hardened execute callback for a list-style ability with a required ID, schema defaults, and backing pagination drift: + +```php +public static function execute_get_thing_details( $input = null ) { + if ( ! is_array( $input ) ) { + $input = []; + } + + // Gotcha 3: required-ID validation (not empty()). + if ( ! isset( $input['thing_id'] ) + || ! is_string( $input['thing_id'] ) + || '' === $input['thing_id'] + ) { + return new \WP_Error( + '_missing_thing_id', + __( 'A thing_id is required.', '' ) + ); + } + + // Gotcha 1: apply defaults the Abilities API doesn't inject. + if ( ! array_key_exists( 'include_history', $input ) || null === $input['include_history'] ) { + $input['include_history'] = false; + } + $input['include_history'] = (bool) $input['include_history']; + + // Gotcha 2: translate pagination before delegating (only if backing reads a non-standard key). + $input = self::translate_pagination_keys( $input ); + + return self::delegate_to_rest_controller( + '', + 'get_thing', + '/my-plugin/v1/things/' . rawurlencode( $input['thing_id'] ), + $input + ); +} +``` + +Each of the three lines the callback adds on top of the "just delegate" pattern has a paid-for bug behind it. Keep them. diff --git a/.agents/skills/wp-abilities-api/references/php-registration.md b/.agents/skills/wp-abilities-api/references/php-registration.md new file mode 100644 index 0000000..8f2ff52 --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/php-registration.md @@ -0,0 +1,94 @@ +# PHP registration quick guide + +Key concepts and entrypoints for the WordPress Abilities API: + +- Register ability categories and abilities in PHP. +- Use the Abilities API init hooks to ensure registration occurs at the right lifecycle time. + +## Hook order (critical) + +**Categories must be registered before abilities.** Use the correct hooks: + +1. `wp_abilities_api_categories_init` — Register categories here first. +2. `wp_abilities_api_init` — Register abilities here (after categories exist). + +**Warning:** Registering abilities outside `wp_abilities_api_init` triggers `_doing_it_wrong()` and the registration will fail. + +```php +// 1. Register category first +add_action( 'wp_abilities_api_categories_init', function() { + wp_register_ability_category( 'my-plugin', [ + 'label' => __( 'My Plugin', 'my-plugin' ), + ] ); +} ); + +// 2. Then register abilities +add_action( 'wp_abilities_api_init', function() { + wp_register_ability( 'my-plugin/get-info', [ + 'label' => __( 'Get Site Info', 'my-plugin' ), + 'description' => __( 'Returns basic site information.', 'my-plugin' ), + 'category' => 'my-plugin', + 'execute_callback' => 'my_plugin_get_info_callback', + 'permission_callback' => 'my_plugin_get_info_permissions', + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => [ + 'public' => true, // Expose via the bundled WordPress MCP adapter. + ], + 'annotations' => [ + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ], + ], + ] ); +} ); +``` + +## Common primitives + +- `wp_register_ability_category( $category_id, $args )` +- `wp_register_ability( $ability_id, $args )` + +## Key arguments for `wp_register_ability()` + +| Argument | Required? | Description | +|----------|-----------|-------------| +| `label` | **Required** | Human-readable name for UI (e.g., command palette). | +| `description` | **Required** | What the ability does. | +| `category` | **Required** | Category ID (must be registered first via `wp_abilities_api_categories_init`). | +| `execute_callback` | **Required** | Function that runs when the ability is invoked. Receives mixed input (per `input_schema`), returns mixed result or `WP_Error`. | +| `permission_callback` | **Required** | Function that checks whether the current user may execute. Receives the same mixed input as `execute_callback`; returns `bool` or `WP_Error`. WP core throws `InvalidArgumentException` if this is missing — there is no implicit default. | +| `input_schema` | Optional | JSON Schema for expected input (enables validation). Required when the ability accepts input. | +| `output_schema` | Optional | JSON Schema for returned output (enables validation of the result). | +| `meta.show_in_rest` | Optional (default `false`) | Set `true` to expose via the `wp-abilities/v1` REST API namespace. | +| `meta.mcp.public` | Optional (default `false`) | Set `true` to expose the ability as a tool via the bundled WordPress MCP adapter. Independent from `show_in_rest`. | +| `meta.mcp.type` | Optional (default `'tool'`) | One of `'tool'`, `'resource'`, `'prompt'`. Controls how the bundled MCP adapter projects the ability. Values outside this enum silently coerce to `'tool'`. | +| `meta.annotations.readonly` | **Strongly recommended** (default `null`) | `true` if the ability does not modify its environment. | +| `meta.annotations.destructive` | **Strongly recommended** (default `null`) | `true` if the ability may perform destructive updates. `false` for additive-only updates. | +| `meta.annotations.idempotent` | **Strongly recommended** (default `null`) | `true` if calling the ability repeatedly with the same arguments has no additional effect. | + +The three annotations under `meta.annotations` are *hints* for tooling and documentation — core does not enforce them at runtime, so a missing or `null` value is silently legal. That permissiveness is exactly why every registration should populate them explicitly: MCP / Command Palette / agent surfaces and review tooling reason about ability safety from these values *without* invoking the callback. A `readonly: null` ability is treated as "behavior unknown," which is a worse signal than either `true` or `false`. Treat the absence of an annotation as a bug, not a default. + +### `show_in_rest` vs `mcp.public` — they target different surfaces + +These two meta keys answer different questions and do not imply each other: + +- `show_in_rest` controls visibility on the WordPress core REST namespace `wp-abilities/v1` (the abilities REST API). Clients that talk to that namespace see the ability iff this is `true`. +- `mcp.public` is read by the bundled WordPress MCP adapter package. The adapter's default MCP server only surfaces abilities whose `meta.mcp.public` is strictly `true`. Without it, the ability is registered but invisible to MCP clients connecting through that adapter. + +A plugin can set both, either, or neither. If you want the ability discoverable to agents through MCP, set `mcp.public => true`. If you also want it on the abilities REST namespace (for tooling that talks to `wp-abilities/v1` directly), set `show_in_rest => true`. The two surfaces are independent. + +## Recommended patterns + +- Namespace ability IDs as `/` (e.g., `my-plugin/get-info`, `my-plugin/update-thing`). Slash-separated. +- Treat IDs as stable API; changing an ID is a breaking change for any consumer that holds a reference. +- Use `input_schema` and `output_schema` for validation and to help AI agents understand usage. +- **Always include a `permission_callback`.** It is required on every registration — there is no implicit default. +- **Always set all three `meta.annotations` keys (`readonly`, `destructive`, `idempotent`) explicitly.** Leaving them at the `null` default broadcasts "behavior unknown" to every consumer that reads this metadata before invoking the ability. The cost of writing them is three lines; the cost of omitting them is opaque safety surface. + +## References + +- Abilities API handbook: https://developer.wordpress.org/apis/abilities-api/ +- Dev note: https://make.wordpress.org/core/2025/11/10/abilities-api-in-wordpress-6-9/ +- WordPress MCP adapter package — source of `meta.mcp.public` / `meta.mcp.type` contract. The adapter ships as a packaged dependency; verify the meta-key names against the package version your plugin pulls in. diff --git a/.agents/skills/wp-abilities-api/references/plugin-family-patterns.md b/.agents/skills/wp-abilities-api/references/plugin-family-patterns.md new file mode 100644 index 0000000..df548cc --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/plugin-family-patterns.md @@ -0,0 +1,233 @@ +# Plugin-family patterns + +This reference covers shared implementation *mechanics* — the call shape your execute callbacks follow when handing work to existing business logic. Two shapes are common enough across real-world WordPress plugins to be worth naming; which one fits is determined by how the backing controller is constructed, not by a choice you make up front. The choice still ripples through your delegate helper, your tests, and your error codes, so it's worth confirming early. + +Family-specific *registration* conventions — loader path, ability category, MCP exposure defaults, error-code prefix house style — live in separate plugin-family overlays (for example, a WooCommerce-extension overlay), not in this reference. Apply any relevant overlay before scaffolding registration. The patterns below should stay portable: they describe how the execute callback talks to backing code, not what the registration metadata should look like for your plugin family. + +There is also a third option that sits *outside* this dichotomy. If the operation does not yet have a shared service class — or if you can refactor toward one — extracting a service that the ability, the REST controller, and the UI all consume is the default per `shared-core-service.md`. Delegation through an existing REST controller (either shape below) is a conditional shortcut for low-stakes reads, not the starting point. Confirm the shape is right before you reach for either. + +## Shape: shared API client + +Common in plugins that talk to a remote service (Stripe, a first-party SaaS, an upstream API). The plugin bootstraps a single API client, exposes it via a static accessor on a main plugin class, and every REST controller takes that client as a constructor argument. + +### Detection + +```bash +# Inspect the backing REST controller's __construct signature. +grep -n "public function __construct" +``` + +If the constructor takes a required typed argument (e.g. `My_Plugin_API_Client $api_client`), you're in the shared-API-client shape. Confirm the plugin has a central accessor: + +```bash +grep -rn "get_api_client\|get_.*_api_client\|get_service" +``` + +You're looking for a `public static function get_()` that returns the shared client. + +### Minimal skeleton + +```php + __( 'Get things', 'my-plugin' ), + 'description' => __( 'List things with filters.', 'my-plugin' ), + 'category' => self::CATEGORY_SLUG, + 'input_schema' => [ /* ... */ ], + 'execute_callback' => [ __CLASS__, 'execute_get_things' ], + 'permission_callback' => [ __CLASS__, 'current_user_has_capability' ], + 'meta' => [ + 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], + 'show_in_rest' => true, + ], + ] + ); + } + + public static function execute_get_things( $input = null ) { + return self::delegate_to_rest_controller( + 'My_Plugin_REST_Things_Controller', + 'get_things', + '/my-plugin/v1/things', + is_array( $input ) ? $input : null + ); + } + + private static function delegate_to_rest_controller( $controller_class, $method, $route, $input, $http_method = 'GET' ) { + $fqcn = '\\' . $controller_class; + if ( ! class_exists( $fqcn ) ) { + return new \WP_Error( 'my_plugin_not_initialized', __( 'My Plugin is not initialized.', 'my-plugin' ) ); + } + + // Shape specifics: fetch the shared API client and null-check. + $api_client = null; + if ( class_exists( '\My_Plugin' ) && method_exists( '\My_Plugin', 'get_api_client' ) ) { + $api_client = \My_Plugin::get_api_client(); + } + if ( null === $api_client ) { + return new \WP_Error( 'my_plugin_not_initialized', __( 'My Plugin is not initialized.', 'my-plugin' ) ); + } + + $request = new \WP_REST_Request( $http_method, $route ); + if ( is_array( $input ) ) { + foreach ( $input as $param => $value ) { + $request->set_param( $param, $value ); + } + } + + $controller = new $fqcn( $api_client ); + $response = $controller->{$method}( $request ); + + if ( is_wp_error( $response ) ) { + return $response; + } + if ( $response instanceof \WP_REST_Response ) { + $data = $response->get_data(); + return is_array( $data ) ? $data : []; + } + return is_array( $response ) ? $response : []; + } +} +``` + +### Testing implications + +- **Bootstrap test doubles need the API client.** Unit tests that instantiate controllers must provide a mocked or stubbed client; otherwise the constructor fails with `ArgumentCountError: Too few arguments`. +- **The "not initialized" error path is reachable and must be covered.** One unit test should stub the accessor to return null and assert the ability returns `_not_initialized`. This is a common failure during partial bootstraps (WP-CLI with restricted loading, test harnesses, admin pages with conditional autoloading). +- **Integration tests need real or faked HTTP.** The backing controller will hit the upstream unless the API client is mocked, so integration harnesses typically route through a fake transport. + +Worked example: a plugin that talks to an external SaaS or upstream HTTP service typically follows this pattern. The plugin's main class exposes the API client via a static accessor (e.g. `Plugin_Main::get_api_client()`); REST controllers extend a base class whose constructor takes that client as a typed argument; the abilities registrar pulls the client through the same accessor before constructing controllers. + +## Shape: zero-arg controllers + +Common in plugins/packages that delegate primarily to WordPress core mechanisms (custom post types, options, meta) and don't maintain a single shared API client. Controllers instantiate cleanly without a dependency graph. + +### Detection + +```bash +grep -n "public function __construct" +``` + +If the constructor takes no required arguments, or takes only simple scalars you can hardcode at the ability call site (e.g. a post-type string), you're in the zero-arg shape. + +Also grep the plugin's main bootstrap class for the absence of a singleton API accessor — if there isn't one, the zero-arg shape is the honest choice. + +### Minimal skeleton + +```php + [ __CLASS__, 'execute_get_things' ], + /* ... */ + ] + ); + } + + public static function execute_get_things( $input = null ) { + $input = is_array( $input ) ? $input : []; + $endpoint = new \My_Plugin_Things_Endpoint(); + $request = new \WP_REST_Request( 'GET', '/my-plugin/v1/things' ); + + foreach ( [ 'page', 'per_page', 'status', 'search' ] as $key ) { + if ( isset( $input[ $key ] ) ) { + $request->set_param( $key, $input[ $key ] ); + } + } + + $response = $endpoint->get_items( $request ); + if ( is_wp_error( $response ) ) { + return $response; + } + return $response instanceof \WP_REST_Response ? $response->get_data() : $response; + } +} +``` + +No helper is required — the construction step is a single line and inlining per callback stays readable. If you do extract a helper, it takes a `constructor_args` parameter because the controller class isn't constant across abilities: + +```php +/** + * @param string $controller_class Fully-qualified controller class. + * @param string $method Method to invoke on the request. + * @param string $route REST route used when constructing WP_REST_Request. + * @param array|null $input Ability input (or null). + * @param string $http_method HTTP method. Defaults to 'GET'. + * @param array $constructor_args Optional scalar args for the controller's constructor. + * @return array|\WP_Error + */ +private static function delegate_to_rest_controller( + $controller_class, + $method, + $route, + $input, + $http_method = 'GET', + $constructor_args = array() +) { + /* ... */ +} +``` + +The phpDoc carries the `array|\WP_Error` return shape; the function signature itself stays untyped on the return so this snippet parses on PHP 7.2+. + +See `delegate-helper-pattern.md` for the full helper signature and guards. + +### Testing implications + +- **No API-client mock needed.** Unit tests instantiate the ability registrar directly and call `execute_*` with input arrays. +- **Test at the `wp_get_ability()` level when possible.** With zero-arg controllers the backing often exists in WordPress core territory (e.g. custom post types), so integration tests can exercise the full pipeline without a fake transport. +- **The `_not_initialized` failure mode is less common.** It still exists — if a package isn't loaded, `class_exists` on the backing controller still fails — but the surface area is smaller. + +Worked example: a plugin whose REST controllers wrap a custom post type, taxonomy, option, or meta typically follows this pattern. Each execute callback constructs its controller inline (e.g. `new My_CPT_Endpoint( 'my_cpt' )`), passing whatever scalar (post-type slug, taxonomy name) the controller needs. No shared helper in the registrar; the construction step is small enough to inline. + +## Hybrid cases + +A single plugin can legitimately use both patterns across different abilities: + +- A shared-API-client ability for backing controllers that talk to the upstream service. +- A zero-arg ability for backing controllers that only touch WP core (e.g. a CPT-based settings endpoint in the same plugin). + +If this happens, the helper in `delegate-helper-pattern.md` can support both via optional `constructor_args` — but resist the temptation to over-engineer until you have at least two zero-arg abilities that would share the helper. + +## Anti-pattern — inventing an API client where there isn't one + +Don't introduce a fake shared API client to fit the shared-client helper shape. If the plugin is genuinely stateless / CPT-driven, inline construction is the honest answer. The helper is scaffolding that pays back when you have 4+ abilities sharing the build-request → instantiate → unwrap flow; before that, inline code is faster to read and review. + +## Picking quickly + +| Signal | Likely pattern | +|---|---| +| Backing controller's `__construct` takes an API-client-like object | A | +| Plugin has a `Plugin::get_*_client()` static accessor | A | +| Plugin talks to an external SaaS / upstream HTTP service | A | +| Backing controller `__construct` is zero-arg or only takes scalars | B | +| Backing is a CPT / options / meta wrapper | B | +| Plugin's REST surface wraps WP-core post types, taxonomies, options, or meta | B | diff --git a/.agents/skills/wp-abilities-api/references/rest-api.md b/.agents/skills/wp-abilities-api/references/rest-api.md new file mode 100644 index 0000000..9aaa7e8 --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/rest-api.md @@ -0,0 +1,13 @@ +# REST API quick guide (`wp-abilities/v1`) + +The Abilities API exposes endpoints under the REST namespace: + +- `wp-abilities/v1/abilities` +- `wp-abilities/v1/categories` + +Debug checklist: + +- Confirm the route exists under `wp-json/wp-abilities/v1/...`. +- Verify the ability/category shows in REST responses. +- If missing, confirm `meta.show_in_rest` is enabled for that ability. + diff --git a/.agents/skills/wp-abilities-api/references/shared-core-service.md b/.agents/skills/wp-abilities-api/references/shared-core-service.md new file mode 100644 index 0000000..57ccf5f --- /dev/null +++ b/.agents/skills/wp-abilities-api/references/shared-core-service.md @@ -0,0 +1,184 @@ +# Shared core service — keeping abilities in lockstep with REST and UI + +When an ability mirrors something a human can already do through a supported UI or workflow, the ability MUST consume the same code path as that UI or workflow — same permissions, same validation, same business rules. Three call sites for the same operation (UI, REST, ability — possibly CLI too) drift apart over time unless they all delegate to a shared service. + +The default shape this reference recommends is a shared service that the ability, the REST controller, and the UI all consume. Routing the ability through the existing REST controller (the "delegation pattern" below) is a conditional shortcut — fine when the controller is a pure data-fetch and the ability runs predominantly inside REST contexts, but the wrong default for any ability with real growth surface. This reference covers when the shortcut is safe, when it isn't, and the side effects on the existing REST path that most often disqualify it. + +Read `domain-vs-projection.md` first — abilities are use-case contracts at the domain layer; UI/REST/CLI/MCP are projections. This reference is the implementation mechanism that keeps those projections honest. + +## Why this matters + +A registered ability that re-implements logic instead of calling into existing code paths is a drift hazard. The drift surfaces in predictable ways: + +- A new filter added to the UI listing doesn't propagate to the ability. +- A permission tightened on the REST controller leaves the ability under-gated. +- A validation rule added to the admin form is missing from the ability. +- A status transition gains a side-effect (audit log, webhook) that fires on UI-driven writes but not ability-driven writes. + +None of these break the ability immediately. They become discoverable only when an agent invokes the ability and the result quietly diverges from what the same operation in the UI would produce. By that point the ability is in production and the divergence is hard to detect without a contract test you almost certainly do not have. + +The fix is to keep one source of business logic and treat ability/REST/UI/CLI as adapters over it. + +## Three shapes for the ability execute callback + +| Shape | Example | Verdict | +|---|---|---| +| **Re-implement** the logic in the execute callback. | The callback runs its own SQL, applies its own permission checks, builds its own response. | **Avoid.** Guaranteed drift the first time the original code path changes. | +| **Delegate to the existing REST controller** via `WP_REST_Request`. | The ability builds a `WP_REST_Request` from its input, dispatches via `rest_do_request()`, and returns the response data. The dispatched request flows through the registered controller's permission check, validation, and handler. | **Conditional shortcut.** Acceptable only when *all three* hold: the backing handler is a pure data-fetch (read-only, no side effects), the operation is itself a read, and the ability runs predominantly inside REST contexts. Read "Side effects on the existing REST path" below before assuming the conditions hold. | +| **Extract a service class** that ability + REST controller + UI handler all consume. | `My_Plugin::get_things_service()->list( $args )` called from the ability, the REST controller, and an admin-side handler. | **Default.** Choose this unless every delegation condition above holds. It is the only shape that removes drift entirely and the only shape that scales when an endpoint later grows a side effect, a write counterpart, or a non-REST caller. | + +The third row is the default because it is the only one that decouples the ability (a domain-layer capability) from the REST transport. The middle row is a real shortcut — there is no point extracting a service class for `get_option('something')` — but its applicability is bounded enough that it should be reached for deliberately, not by default. Extracting a service is more invasive at first (it usually means refactoring the existing REST controller to call the service rather than embed the logic), but it is also the move that holds up under change. + +## What the delegation pattern looks like + +The middle row of the table — "delegate to the existing REST controller" — is a pattern, not a single canonical helper. The minimal shape is small enough to inline in an ability's `execute_callback`: + +```php +public static function execute_get_things( $input = null ) { + $request = new WP_REST_Request( 'GET', '/my-plugin/v1/things' ); + $request->set_query_params( (array) $input ); + + $response = rest_do_request( $request ); + if ( $response->is_error() ) { + return $response->as_error(); + } + + return $response->get_data(); +} +``` + +The five moves: construct the `WP_REST_Request` with the right HTTP method and route; copy the ability's `$input` onto the request as query params (or body params for writes); dispatch via `rest_do_request()`, which routes through the registered controller including its `permission_callback` and any side effects; convert an error response back to a `WP_Error` so the ability's caller sees a normal error; otherwise return the data. + +In a real codebase you would usually extract this into a small private helper so multiple ability callbacks can share the boilerplate. The shape of that helper is out of scope for this reference — the point here is that the delegation pattern is mechanically simple and does not depend on any particular framework helper to be in place. + +## Side effects on the existing REST path + +A delegating ability re-uses the REST controller's full code path, including any side effects the controller emits on every call: + +- **Usage telemetry / analytics events** — the ability inflates dashboards with agent traffic and the human-vs-agent provenance signal is lost. Metric double-counting is the silent failure mode — the system "works" while the dashboards drift. +- **Audit logs** — entries get attributed to a "REST" actor that is actually an agent. Forensics gets noisier. +- **Custom-event hooks** (`do_action(...)`) — listeners on those hooks now fire on every ability invocation, with surprise side-effects in unrelated subsystems. +- **Email / notification dispatch** — agent-driven calls trigger user-visible notifications that should not have been sent. +- **Cache invalidation, schedule rescheduling, lock acquisition** — harmless when intended; harmful when fired by traffic the original handler did not anticipate. +- **First-call REST bootstrap cost** (performance, not semantics). `rest_do_request()` calls `rest_get_server()`, which lazily instantiates `WP_REST_Server` and fires `rest_api_init` the first time it's invoked in a request lifecycle. In a normal HTTP REST request the cost is paid before the abilities layer even runs; in CLI / cron / agent / non-REST MCP transports, the *first* ability that delegates pays it — every plugin's `register_rest_route()` callback wires up at this point. The cost is one-time per request lifecycle (`rest_get_server()` guards on `if ( empty( $wp_rest_server ) )`), but on a cold path the first invocation is measurably slow. If the ability is expected to run predominantly outside REST contexts, prefer calling the underlying service or request class directly over going through `rest_do_request()`. + +The fix is the third row of the table above: + +- Extract the business logic into a service class. +- Have the REST controller call the service AND emit its side effects as a thin adapter. +- Have the ability call the service directly, NOT the REST controller. The ability emits its own ability-tagged side effects, or none. + +This way side effects stay scoped to the surface that triggers them, and the ability still consumes the same business logic as the UI. + +If the existing REST endpoint is a pure data-fetch with no side effects, the delegation pattern is a fine shortcut. + +## MCP exposure rule + +Do not expose REST AND ability for the same operation to the same MCP client. Pick one. + +The ability is the agent contract: schema-typed, permission-gated, semantic-intent-named. The REST endpoint stays in place for non-agent integrations (UI, third-party clients that already exist, internal services that consume the JSON contract). The MCP layer surfaces the ability and elides the REST endpoint. + +Exposing both produces a "which surface should I use?" question for any LLM that sees both — and the answer affects metrics, logging, and error handling. Pick the ability. + +## The `AGENTS.md` rule + +Add a line to the plugin's `AGENTS.md` (under whichever H2 covers "when changing code in this area"): + +> When you change the code path behind a registered ability, check whether the ability needs to update too. A new filter on the underlying listing usually means the ability should expose the same filter. A permission change means the ability's gate likely needs to follow. A new side-effect on a write may change what we promise the ability does. + +This shifts the burden from "remember to update the ability" to "be reminded by the LLM working on the change." It costs nothing at write time and prevents the most common source of drift. + +## Worked example — extracting a service + +Generic plugin with a `Things` resource. Before extraction, the REST controller embeds the listing logic and the ability re-runs the same filter/sort/paginate code: + +```php +// includes/admin/class-rest-things-controller.php +class My_Plugin_REST_Things_Controller { + public function get_things( WP_REST_Request $request ) { + $args = self::sanitize_query_args( $request->get_params() ); + $rows = $this->repo->find( $args ); + + // Side effects (audit log, hooks, notifications) live on the REST adapter. + do_action( 'my_plugin/things_listed', $rows ); + + return rest_ensure_response( array_map( [ $this, 'format' ], $rows ) ); + } +} + +// src/Internal/Abilities/Abilities_Registrar.php +public static function execute_get_things( $input = null ) { + // PROBLEM: re-implements sanitization, repo call, formatting. + // Drifts the first time the controller's get_things() changes. + $args = MyPlugin\Sanitize::query_args( (array) $input ); + $rows = ( new MyPlugin\Things_Repo() )->find( $args ); + return array_map( [ MyPlugin\Things_Formatter::class, 'format' ], $rows ); +} +``` + +After extraction, both paths consume `Things_Service::list()`: + +```php +// src/Service/class-things-service.php +class Things_Service { + public function list_things( array $args ) { + $clean = Sanitize::query_args( $args ); + $rows = $this->repo->find( $clean ); + return array_map( array( Things_Formatter::class, 'format' ), $rows ); + } +} + +// includes/admin/class-rest-things-controller.php +class My_Plugin_REST_Things_Controller { + public function get_things( WP_REST_Request $request ) { + $rows = $this->service->list_things( $request->get_params() ); + + // Side effects (audit log, hooks, notifications) stay on the REST adapter — clean. + do_action( 'my_plugin/things_listed', $rows ); + + return rest_ensure_response( $rows ); + } +} + +// src/Internal/Abilities/Abilities_Registrar.php +public static function execute_get_things( $input = null ) { + if ( ! class_exists( '\MyPlugin\Things_Service' ) ) { + return new WP_Error( 'myplugin_not_initialized', __( 'My Plugin is not initialized.', 'my-plugin' ) ); + } + return MyPlugin::get_things_service()->list_things( (array) $input ); +} +``` + +The ability and the REST endpoint now share business logic. Side effects (audit, hooks, notifications, any telemetry) stay on the REST adapter and do not fire on agent-driven invocations. The next person to add a filter parameter changes one place — the service — and both call sites pick it up. + +## Rule of thumb + +Start from the assumption that the ability extracts (or consumes an already-extracted) service. Walk back to delegation only when every condition for it holds. + +- **Write of any kind** → extract a service. Drift is most damaging on writes (lost validation, missing audit hooks). Non-negotiable. +- **No existing REST endpoint** → start at the service. The first ability you ship is also the right time to add the structure that a future REST endpoint will consume. +- **Read where the REST handler does more than data-fetch** (audit, hooks, notifications, telemetry) → extract a service. Don't fire UI-scoped side effects on agent invocations. +- **Read predominantly invoked outside REST** (CLI, cron, agent, non-REST MCP transport) → prefer direct invocation of the service or the underlying request class. Delegation pays the first-call REST bootstrap cost on every cold path. +- **Read with no side effects on the REST path, light logic, predominantly invoked through REST** → the delegation pattern is acceptable as a shortcut. Drift risk is bounded because the REST controller is one short hop away — but only as long as those three conditions hold. + +The bias is intentional. Service-extraction is the only shape that holds up when the REST handler later grows a side effect, when a write counterpart shows up, or when a non-REST caller appears (a new CLI command, a cron job, an agent invocation off a non-REST MCP transport). Delegation re-couples the domain layer to the REST transport; that re-coupling is fine when the conditions hold *and* unlikely to change, but the cost of unwinding it later is higher than starting at the service. + +## Escape hatch — when re-implementation is OK + +Two narrow cases: + +1. **The ability is read-only and the backing has no extractable shape.** Sometimes the "logic" is one line — `get_option( 'something' )` — and a service class is overkill. Inline it. +2. **The plugin is single-purpose and will not grow surfaces.** A 200-line plugin with one ability and no REST surface to drift against can keep logic in the execute callback. The drift risk only shows up at 2+ adapters. + +In both cases, leave a `// TODO: extract to service if a REST/UI surface gets added` comment so the next person sees the trigger condition. + +## Plugin-family override + +This reference describes the generic baseline. Specific plugin families can — and routinely do — tighten the rules further. A payments-family plugin might forbid the delegation pattern outright on the grounds that no payments endpoint is safe to treat as side-effect-free. A subscription plugin might require service extraction even for one-line option reads if the option is read in more than one transport. A plugin that ships its own MCP transport might rule out delegation for any ability exposed through it. + +When the plugin you are working in is one of those, the local rules win. Check the plugin's `AGENTS.md`, contributor guide, or ability-registration playbook before reaching for the delegation shortcut taught here. + +## Related references + +- `domain-vs-projection.md` — the layer model that puts shared services at the domain layer and projections (REST / MCP / CLI / UI) above. +- `grouping-heuristic.md` — orthogonal: this reference covers "same code path"; that one covers "how many abilities." diff --git a/.agents/skills/wp-abilities-audit/SKILL.md b/.agents/skills/wp-abilities-audit/SKILL.md new file mode 100644 index 0000000..d2b9654 --- /dev/null +++ b/.agents/skills/wp-abilities-audit/SKILL.md @@ -0,0 +1,198 @@ +--- +name: wp-abilities-audit +description: "Audit a WordPress plugin's REST surface and produce a standardized audit document proposing Abilities API registrations. Produces a markdown doc with a YAML schema and prose sections that humans and agents can both consume when planning a registration rollout. Works on any WP plugin." +compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+). Filesystem-based agent with bash + node. Requires access to the plugin checkout; some workflows benefit from WP-CLI but don't require it." +--- + +# WP Abilities Audit + +Produce a standardized audit document for a WordPress plugin's REST surface, +proposing a set of Abilities API registrations grouped by semantic intent. The +audit doc is a planning artifact for implementers — humans, agents, or both — +that captures the controller inventory, capability gates, and proposed +ability shapes in a structured form. A reviewer reading the doc can scope the +work without re-deriving the survey. + +This skill works on any plugin that exposes a REST surface. Plugin +classification (for purposes of the optional `plugin_family` annotation) is +the user's call; the workflow itself is plugin-agnostic. + +## When to use + +- The task is "register Abilities API abilities for a WP plugin" and no audit + doc exists yet. +- Planning participation in a multi-plugin abilities rollout and need a + shareable, standardized audit artifact. +- Pre-flight checking a plugin's agent-readiness before implementing abilities. +- A PM or non-implementer wants to scope the work before engineering picks it up. + +## Inputs required + +1. **Plugin checkout path** — working tree of the plugin to audit. +2. **Triage output** — run `wp-project-triage` first if not already done. The + audit consumes `signals.usesAbilitiesApi`, `versions.wordpress`, and + `project.kind` from the report. +3. **Auditor identity** — name and team or context, recorded in the audit's + `auditor` field. +4. **Output path** — where the audit doc should land. Default explicit over + implicit; ask if not provided rather than writing into the plugin worktree. + +## Prerequisites + +- `wp-project-triage` has run successfully and classified the plugin. +- The plugin has at least one REST controller. If enumeration finds zero + controllers, the audit doesn't apply — see "Failure modes" below. + +## Procedure + +### 1. Enumerate REST controllers + +Read `references/controller-enumeration.md` now — it covers the two observed +enumeration paths (glob for standard layouts, grep as the universal fallback) +and when to use each. + +Record every controller class + file + REST base + routes in a "Controller +Inventory" table. The inventory is exhaustive even though only a subset +becomes proposed abilities. + +### 2. For each controller, extract the backing fields + +For every controller found, extract the fields the audit schema requires: +class, file, HTTP method, route, route-registration line number, callback +name, callback line number, permission callback, whether the callback takes +a `WP_REST_Request` argument or is zero-arg, and the return type. + +Read `references/audit-schema.md` now for the exact field list and the shape +of `proposed_abilities` entries. Line-number fields may be `null` for +inherited callbacks — the schema allows this and pairs it with an optional +`inherited_from` field. + +### 3. Confirm capability gate(s) + +Trace each controller's `permission_callback` to its `current_user_can()` call +(or to the post-type capability machinery if the controller extends a +post-type-backed base). + +Read `references/capability-gate-tracing.md` now — it documents the two +common mechanisms (direct `check_permission()` vs post-type-backed +`wc_rest_check_post_permissions()`) and how to represent each in the schema. +Note explicitly whether read and write gates differ: compound gates are +represented as a `{read, write}` object, not a single string. + +### 4. Propose abilities using semantic-intent grouping + +Do NOT atomize one ability per HTTP method. Apply the semantic-intent grouping +heuristic — it's the only grouping rule this skill uses. + +Read `../wp-abilities-api/references/grouping-heuristic.md` now — do NOT +re-derive the rules here. Short version: one ability per real-world question +or state transition, with filter parameters in `input_schema` collapsing N +variants into 1. + +**Apply the use-case sanity check before populating any candidate.** Per +`../wp-abilities-api/references/domain-vs-projection.md`'s use-case-contract +test: would a human or agent intentionally perform this behavior through a +supported plugin workflow? If yes, the candidate is a real ability — +proceed to fill in fields. If no, the route is internal transport plumbing +(cache invalidation, scheduler ticks, bookkeeping endpoints, debug +introspection) — keep it in the Controller Inventory section for +completeness, but do NOT promote it to `proposed_abilities`. The route may +be useful to inventory; the proposed ability must represent a real +user/operator question or action. + +For each proposed ability that passes the sanity check, fill in every +field in the `proposed_abilities` schema: `name`, `intent`, `backing`, +`permission`, `return_type`, `effort` (S/M/L), `annotations` +(readonly/destructive/idempotent), `notes`, `risks`, `use_case_fit`, +`side_effects`, `seed_data_needs`. + +The last three are the implementation-readiness facts the implementer +and the verify-mode tooling both need: which human/agent workflow this +ability serves (`use_case_fit`), what the backing path emits on every +call (`side_effects` — empty array is a fact, not a missing value), and +what representative data must exist in the test environment for the +ability to execute through the public boundary (`seed_data_needs`). + +### 5. Surface gaps and deferred items + +Three buckets: + +- **`excluded_from_mvp`** — candidates intentionally deferred for risk reasons + (real-money writes, irreversible state changes, or prerequisite design + work). Each entry gets a one-sentence reason. +- **`surfaced_gaps`** — MVP candidates with no backing endpoint (ability with + `backing: null`), plus high-value endpoints discovered during enumeration + that aren't in the MVP list but would be easy future wins. +- **Risks per ability** — anything about a backing endpoint that the + implementer must handle (no idempotency key, two-phase behavior, + state-transition caveats, zero-arg endpoints registered with + `permission_callback => '__return_true'` that must NOT copy that into the + ability registration). + +### 6. Write the audit doc + +Write to the explicit output path collected in "Inputs required". The +document structure must match `references/audit-schema.md` exactly: + +1. `Last updated: YYYY-MM-DD HH:MM` header. +2. YAML block with all required top-level metadata + `proposed_abilities`, + `excluded_from_mvp`, `surfaced_gaps`. +3. "Controller Inventory" table. +4. "Notes and Surprises" prose section. + +A copy-pasteable minimal example showing the full shape lives in +`references/audit-schema.md` under "Minimal valid example" — start there +when authoring a new audit. + +### 7. (Optional) Designate a reference implementation ability + +Set `reference_ability: true` on the first ability an implementer should +land — typically the smallest, safest, highest-leverage read. This gives +downstream workflows a deterministic starting point. + +## Verification + +- The audit conforms to `references/audit-schema.md` (all required top-level + fields present, at least one entry in `proposed_abilities`, annotations + complete on every ability). +- `capability_gate` is a string for single-cap plugins or a `{read, write}` + object for post-type-backed plugins. +- Every ability with `backing: null` also appears in `surfaced_gaps`. +- The doc round-trips through the validator in `audit-schema.md` "Known + limitations" without errors. + +## Failure modes / debugging + +- **Plugin has no REST controllers** — audit doesn't apply. Consider + hooks/filters-based abilities (out of scope for this skill's current + version) or skip abilities adoption for this plugin. +- **Plugin inherits controllers from another repo** (common for plugins + extending core post-type-backed controllers like `WP_REST_Posts_Controller`, + or extension plugins built on a parent's REST classes) — capture with + `backing.inherited_from: ""`. Line-number fields may be + `null` per the schema. +- **Compound capability gate (distinct read/write caps)** — use the + structured `{read, write}` form documented in + `references/capability-gate-tracing.md`. Don't smuggle a `/`-separated + string into a field typed as a single cap. +- **Ambiguous grouping** — route to + `../wp-abilities-api/references/grouping-heuristic.md`. Do not invent + alternative grouping rules in the audit doc. +- **Zero-arg endpoints with `permission_callback => '__return_true'`** — + legal at the REST layer, but the ability's own `permission_callback` must + match the plugin's merchant gate. Never promote `'__return_true'` into an + ability registration. Note this in the ability's `risks`. +- **Output path defaults to plugin worktree** — always ask the user for an + explicit output directory (e.g. their vault `plans/`). Writing the audit + into the plugin's own git history pollutes the worktree and buries the + artifact. + +## Escalation + +- If the plugin uses an enumeration convention not covered by + `references/controller-enumeration.md` (neither the standard glob nor the + grep fallback produces a complete inventory), update that reference with + the new convention and open a PR so future audits cover it deterministically. +- If capability tracing hits a mechanism not covered by + `references/capability-gate-tracing.md`, extend that file rather than + encoding the new case in the audit's "Notes and Surprises" only. diff --git a/.agents/skills/wp-abilities-audit/references/audit-schema.md b/.agents/skills/wp-abilities-audit/references/audit-schema.md new file mode 100644 index 0000000..ffe1215 --- /dev/null +++ b/.agents/skills/wp-abilities-audit/references/audit-schema.md @@ -0,0 +1,300 @@ +# Audit Document Schema + +The canonical schema for an abilities audit doc. Every audit produced by +`wp-abilities-audit` must conform to this schema so downstream tooling +(humans reviewing, agents implementing, or validators like +`wp-abilities-verify`) can consume it without parsing surprises. + +A copy-pasteable minimal example with both a read ability and a write +ability lives under "Minimal valid example" below. + +## File layout + +``` +/-abilities-audit-.md +``` + +`` is explicit — collected from the user, not inferred. Typical +values are the user's vault `plans/` directory or a dedicated audit repo. +Writing into the plugin worktree is discouraged (pollutes git history). + +The body has two parts: + +1. A fenced ` ```yaml ` block holding structured fields (top-level metadata + + `proposed_abilities`, `excluded_from_mvp`, `surfaced_gaps`). +2. Prose sections below: "Controller Inventory" table + "Notes and Surprises". + +A `Last updated: YYYY-MM-DD HH:MM` header sits above everything. + +## Top-level fields (all required) + +| Field | Type | Description | +|---|---|---| +| `plugin` | string | Plugin slug (e.g. `my-plugin`, `tasks-plugin`, `notifications`). | +| `repo` | string | `Owner/Repository`. | +| `branch_audited` | string | Git branch the audit was run against. | +| `audited_at` | string | ISO date (YYYY-MM-DD). | +| `auditor` | string | Human auditor name + team or context (e.g. `Your Name (Your Team)`). | +| `baseline_abilities` | integer | Count of abilities already registered by the plugin at audit time. Usually 0. | +| `capability_gate` | string OR object | The capability gate the base controller resolves to. Accept either a single string (single-cap plugins) OR a `{read, write}` object (post-type-backed or otherwise compound gates). See `capability-gate-tracing.md` for the mechanisms. | +| `plugin_family` | string (optional) | Free-form classification when useful to downstream readers (e.g. `core-post-type`, `forms-engine`, or a project-specific family name). Optional and user-supplied — no canonical enum. Downstream consumers treat unknown values as opaque rather than erroring. | + +### `capability_gate` representations + +Two legal shapes, both consumed by downstream tooling: + +```yaml +# Single-cap plugin (one capability across every controller) +capability_gate: manage_options # confirmed at includes/admin/class-my-plugin-rest-controller.php line 64 +``` + +```yaml +# Compound read/write (post-type-backed plugins typically need this shape; +# read and write resolve to different capabilities) +capability_gate: + read: read_private_pages + write: edit_others_pages + confirmed: true + verified_at: "custom_post_type capability_type='page' → core post-type cap map (wp-includes/post.php map_meta_cap)" +``` + +Plugin-specific capabilities (e.g. WooCommerce's `manage_woocommerce`, +`edit_shop_orders`) are equally valid — substitute your plugin's caps. The +shape is the contract; the literal cap names are project-specific. + +A legacy compound-string form exists in the wild (`" / "`) +and is accepted for backwards compatibility, but the structured form above is +the preferred representation for new audits. + +## `proposed_abilities` — array + +Each entry: + +| Field | Type | Description | +|---|---|---| +| `name` | string | Kebab-case `/`. | +| `intent` | string | One sentence, user-question framed. | +| `backing` | object or `null` | See below. `null` marks an ability with no backing endpoint (a known gap). | +| `permission` | object or `null` | See below. `null` when `backing` is null. | +| `return_type` | string | Short description (e.g. `WP_REST_Response (wrapping array)`). Hint-only; not machine-parsed. | +| `effort` | enum | `S`, `M`, or `L`. | +| `annotations` | object | `{ readonly: bool, destructive: bool, idempotent: bool }`. All three required. | +| `notes` | array of strings | Implementer-facing detail (filter params, edge cases, alternative backings). | +| `risks` | array of strings | Anything the implementer must handle (missing idempotency key, two-phase behavior, `permission_callback => '__return_true'` at the REST layer that must not copy into the ability, etc.). | +| `use_case_fit` | string | One sentence naming the human or agent workflow this ability serves. The use-case-contract check (see `wp-abilities-api/references/domain-vs-projection.md`): if no human would intentionally do this through a supported UI or workflow, the entry probably belongs in `excluded_from_mvp` instead. | +| `side_effects` | array of strings | Side effects the backing path emits on every call: telemetry hooks, audit-log rows, notifications, cache writes. One short line per effect. Empty array (`[]`) when the backing is a pure data-fetch — that is *itself* a load-bearing fact: it is what unlocks the conditional delegation shortcut in `wp-abilities-api/references/shared-core-service.md`. A non-empty array tells the implementer (and downstream verify-mode tooling) that this ability needs the shared-service shape, not the delegate-through-REST shortcut. | +| `seed_data_needs` | string OR `null` | One line describing what representative data must exist in the test environment for the ability to execute through the public boundary and return something meaningful (e.g. `"at least one entity in the plugin's primary table"`, `"no seed required"`). `null` when the auditor has not yet identified the seed shape; downstream verify-mode tooling treats `null` as "ask the implementer" rather than guessing. | +| `reference_ability` | bool (optional) | If `true`, marks this ability as the reference implementation — the first one an implementer should land (smallest, safest, highest-leverage read). Exactly zero or one ability per audit may set this. | + +### `backing: null` semantics + +An ability with `backing: null` is a known gap (the auditor identified a +valuable ability that has no backing endpoint yet). The schema permits this +as a warning, not an error: + +- The ability MUST also appear in `surfaced_gaps` with a one-line rationale. +- Implementers pause for resolution rather than guessing a backing. +- The audit is still valid; `backing: null` is intentional output, not + missing data. + +### `backing` object + +| Field | Type | Description | +|---|---|---| +| `kind` | enum (optional, default `rest_controller`) | The implementation path the ability should use or inspect. One of `rest_controller`, `service`, `helper`, `data_store`. When omitted, defaults to `rest_controller` for backwards compatibility with audits authored before this field landed. The kind tells downstream tooling whether the delegation pattern from `wp-abilities-api/references/shared-core-service.md` applies (only `rest_controller` is a candidate; the others select the shared-service shape from the start). | +| `class` | string | Fully-qualified PHP class name (controller class for `rest_controller`; service / helper / data-store class for the other kinds). May be omitted when `kind: data_store` and the backing is a bare option key, post-meta key, or table without an owning class. | +| `file` | string | Path relative to plugin root. | +| `method` | enum (required when `kind: rest_controller`) | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`. Not applicable when `kind` is `service`, `helper`, or `data_store`. | +| `route` | string (required when `kind: rest_controller`) | Full REST route path. Not applicable to non-REST kinds. | +| `route_registration_line` | integer OR `null` | For `kind: rest_controller`: line number of the `register_rest_route(` call, or `null` when inherited. Omit for other kinds. | +| `callback` | string | For `kind: rest_controller`: controller method name that handles the route. For `kind: service` / `helper`: method name on the service / helper class. For `kind: data_store`: the operation name (`get_option`, `get_post_meta`) or the table-read pattern; may be omitted. | +| `callback_line` | integer OR `null` | Line number of the callback or method definition, or `null` when inherited or not applicable. | +| `inherited_from` | string (optional) | Fully-qualified parent class name when the route and/or callback is inherited from a class outside this plugin's repo (e.g. `WP_REST_Posts_Controller` from WordPress core, or another plugin's REST base class for extension plugins). Pair with `null` line numbers. Lets downstream tooling skip the re-grep step cleanly. Primarily relevant for `kind: rest_controller`. | + +### `permission` object + +| Field | Type | Description | +|---|---|---| +| `source` | enum (optional, default `rest_controller`) | Where the canonical permission for this behavior lives — not always the REST controller's `permission_callback`. One of `rest_controller`, `admin_action`, `service`, `domain_policy`, `post_type_map`, `none`. When omitted, defaults to `rest_controller` for backwards compatibility. `admin_action` for behaviors gated by `check_admin_referer` / `current_user_can` on an admin handler; `service` when a shared method enforces the cap; `domain_policy` for plugins with a policy / authorization layer; `post_type_map` for capabilities resolved through `map_meta_cap` on a post-type cap shadow; `none` for genuinely public behavior. Tells the implementer whether the ability's `permission_callback` can mirror the REST callback or must consult a different source of truth. | +| `callback` | string | The method or function name that enforces the cap at the recorded `source`. For `source: rest_controller`, this is the `permission_callback` value. For `source: admin_action`, the admin handler function or method. For `source: service`, the service method that performs the cap check. | +| `resolves_to` | string | The `current_user_can()` call(s) it ultimately resolves to. For compound gates, include both (e.g. `"current_user_can('read_private_pages')` for read; `current_user_can('edit_others_pages')` for write"). | +| `confirmed` | bool | `true` if verified against source; `false` if inferred. | + +## `excluded_from_mvp` — array + +Abilities intentionally deferred for risk reasons. Each entry: + +| Field | Type | Description | +|---|---|---| +| `name` | string | Proposed ability name (kebab-case). | +| `reason` | string | One sentence why it's deferred. | + +## `surfaced_gaps` — array + +MVP candidates with no backing endpoint (paired with `backing: null` above), +plus high-value endpoints discovered during enumeration that aren't in MVP +but would make future follow-up work. Each entry: + +| Field | Type | Description | +|---|---|---| +| `name` | string | Proposed ability name. | +| `one_line_rationale` | string | Why it would be high-leverage. | + +## Prose sections (required) + +### Controller Inventory + +A Markdown table with columns `Class | File | REST Base | Routes`. Must list +every controller enumeration found, even ones that aren't backing any MVP +ability. This gives reviewers a full picture and catches "why isn't X in the +MVP?" questions. + +### Notes and Surprises + +Free-form prose capturing anything that didn't fit the structured schema: +capability-gate mismatches between controllers, hardcoded route paths, dual +controllers with different output shapes, two-phase endpoint semantics, and +any judgment calls the auditor made. + +## Minimal valid example + +Copy-pasteable starting point for a new audit: + +````markdown +--- +Last updated: 2026-04-20 14:30 +--- + +# Example Plugin Abilities — Phase 1 Audit + +```yaml +plugin: example-plugin +repo: Owner/example-plugin +branch_audited: feat/abilities-example-plugin +audited_at: 2026-04-20 +auditor: Your Name (Your Team) +baseline_abilities: 0 +capability_gate: manage_options # confirmed at includes/rest-api/class-example-rest-controller.php line 32 + +proposed_abilities: + + - name: example-plugin/get-items + intent: "List items with filters (status, owner, date range) so an agent can answer 'which items need attention?' in one call." + backing: + kind: rest_controller + class: Example_REST_Items_Controller + file: includes/rest-api/class-example-rest-items-controller.php + method: GET + route: /example/v1/items + route_registration_line: 26 + callback: get_items + callback_line: 52 + permission: + source: rest_controller + callback: check_permission + resolves_to: "current_user_can('manage_options')" + confirmed: true + return_type: "WP_REST_Response (wrapping array)" + effort: S + annotations: { readonly: true, destructive: false, idempotent: true } + notes: + - "get_items(WP_REST_Request $request) requires a WP_REST_Request; construct one in the ability execute_callback." + risks: [] + use_case_fit: "Agent answers 'which items need attention right now?' in a single call without paging through a UI." + side_effects: [] + seed_data_needs: "at least one item exists in any non-trashed status" + reference_ability: true + + - name: example-plugin/close-item + intent: "Close a single item — terminal state transition, non-reversible." + backing: + kind: service + class: Example_Items_Service + file: src/Service/class-items-service.php + callback: close + callback_line: 88 + permission: + source: service + callback: Example_Items_Service::assert_can_close + resolves_to: "current_user_can('manage_options')" + confirmed: true + return_type: "WP_REST_Response (updated item object)" + effort: M + annotations: { readonly: false, destructive: true, idempotent: false } + notes: + - "Close is terminal — no reopen endpoint exists." + risks: + - "No idempotency key on the backing endpoint; duplicate POSTs may produce inconsistent audit trails." + use_case_fit: "User or agent closes a stale item from a workflow that surfaces stale items (admin list view, daily-digest agent)." + side_effects: + - "fires action `example_plugin/item_closed` (downstream listeners may dispatch email)" + - "writes audit-log row to `example_plugin_audit_log`" + seed_data_needs: "one open item to close; the test must capture the item id before invocation" + +excluded_from_mvp: + - name: example-plugin/delete-item + reason: "Hard delete is irreversible and lacks an undo endpoint; defer until soft-delete is designed." + +surfaced_gaps: + - name: example-plugin/get-overview + one_line_rationale: "A zero-arg overview endpoint answering 'what's the current state of all items?' would be the highest-leverage ability but no backing endpoint exists yet." +``` + +## Controller Inventory + +| Class | File | REST Base | Routes | +|---|---|---|---| +| Example_REST_Items_Controller | includes/rest-api/class-example-rest-items-controller.php | example/v1/items | GET /example/v1/items, POST /example/v1/items/{id}/close | + +## Notes and Surprises + +### Capability gate is uniform +Every controller inherits `Example_Base_REST_Controller` and uses +`check_permission` verbatim as the `permission_callback`. No per-route +overrides. Safe to treat `manage_options` as the single gate. +```` + +## Known limitations + +Documented so downstream skills have an explicit contract: + +- **`capability_gate` string-with-inline-comment form** loses data when parsed + by strict YAML parsers (comments are dropped). The structured object form is + preferred; string form is accepted for backwards compatibility. +- **Legacy compound-string `capability_gate`** — the `" / "` + form predates the structured `{read, write}` object and is still accepted + for backwards compatibility. Validators (e.g. `wp-abilities-verify`) + emit WARN on this form to nudge migration to the structured shape; + they do NOT FAIL. New audits should use the object form. +- **`return_type` is hint-only.** Prose for the human auditor; not + machine-parseable. Downstream skills use runtime `is_wp_error(...)` and + `instanceof WP_REST_Response` checks regardless of what this field says. +- **Line numbers drift.** `route_registration_line` and `callback_line` are + captured at audit time and may bit-rot. Downstream skills re-locate routes + by `(class, callback)` and do not rely on exact line numbers. +- **`inherited_from` + `null` line numbers** are the canonical way to + represent routes/callbacks defined in a parent class that lives outside + the plugin repo. +- **`backing: null` invariant.** Abilities with `backing: null` are intentional + gaps and MUST also appear in `surfaced_gaps` by `name`. Validators FAIL + audits where this invariant is violated (a `null` backing without a + matching `surfaced_gaps` entry indicates inconsistent audit output). +- **Implementation-readiness fields added 2026-05-21.** `use_case_fit`, + `side_effects`, and `seed_data_needs` are required in the per-ability + schema as of this date. Audits authored against an earlier revision of + this schema will be missing the three fields. Validators (e.g. + `wp-abilities-verify`) emit WARN on missing implementation-readiness + fields to nudge backfill the next time the audit is touched; they do + NOT FAIL, mirroring the legacy `capability_gate` posture above. New + audits MUST populate all three. +- **`backing.kind` and `permission.source` added 2026-05-21.** Both + are optional with default `rest_controller` so older audits validate + as-is. New audits SHOULD populate both explicitly — `backing.kind` + to record whether the ability backs a REST controller, a shared + service, a helper, or a data store (because the answer drives the + delegate-vs-extract-service decision in `wp-abilities-api/references/ + shared-core-service.md`); `permission.source` to record where the + canonical permission lives (REST callback is the common case, but + admin actions, service methods, domain policies, and post-type cap + maps each happen). Validators treat a missing field as the default, + not as an error. diff --git a/.agents/skills/wp-abilities-audit/references/capability-gate-tracing.md b/.agents/skills/wp-abilities-audit/references/capability-gate-tracing.md new file mode 100644 index 0000000..274d6df --- /dev/null +++ b/.agents/skills/wp-abilities-audit/references/capability-gate-tracing.md @@ -0,0 +1,197 @@ +# Capability-Gate Tracing + +How to resolve the actual capability (or capabilities) a plugin's REST +controllers gate on. The audit's `capability_gate` field and each ability's +`permission.resolves_to` field need to reflect reality, not what the +controller docblock says. + +Two common mechanisms cover most plugins. Document both explicitly so the +auditor doesn't hard-code one plugin family's assumptions. + +## Mechanism A — Direct (`check_permission()` returning a single cap) + +The base REST controller declares a `check_permission()` (or +`permissions_check()`) method that calls `current_user_can('')` +once. Every route in the controller uses that method as +`permission_callback`. + +### Identifying signs + +- The base controller has a method like: + ```php + public function check_permission() { + return current_user_can( 'manage_options' ); + } + ``` +- Controllers extend the plugin's own base, not a WordPress core + post-type-backed class. +- The grep `grep -n 'current_user_can' .php` yields one hit. + +### How to trace + +```bash +# Locate the base controller (usually the parent of every REST controller). +grep -rn 'extends .*REST_Controller' includes/ | head + +# Read its permission_callback implementation. +grep -n 'check_permission\|permissions_check' .php +``` + +Trace once: the single `current_user_can()` call is the plugin's gate. + +### How to represent in the audit + +```yaml +capability_gate: manage_options # confirmed at includes/admin/class--rest-controller.php line 64 +``` + +Plugin-specific capabilities (e.g. WooCommerce's `manage_woocommerce` for +shop-aware contexts, Jetpack Forms' `edit_pages`) substitute for +`manage_options` cleanly — the shape stays the same. + +## Mechanism B — Post-type-backed (core CPT capability machinery) + +The controller extends a WordPress core post-type-backed class that +dispatches to the post-type capability map. There is no local +`check_permission()` — the permission callback resolves dynamically at +request time based on the request context (read vs write) and the post +type's `cap` object. + +### Identifying signs + +- The controller's base class is one of: + - `WP_REST_Posts_Controller` — the core post-type REST base. + - A subclass of it, in or out of this plugin's repo. +- No local `check_permission()` — permission callbacks are inherited. +- The post type is registered with `capability_type => ''`, + and the cap map is resolved by core's `map_meta_cap()`. + +### How to trace + +```bash +# Find the post-type registration. +grep -rn "register_post_type\s*(\s*['\"]['\"]" . + +# Read the registration block. The relevant fields are: +# - capability_type: the type whose cap map this post type uses. +# A custom post type can either declare its own caps or shadow another +# type's (e.g. capability_type => 'page' to reuse Pages' caps). +# - capabilities: optional explicit cap-string overrides. +# - map_meta_cap: whether meta caps (read_post, edit_post) get mapped to +# primitive caps (read_private_s, edit_others_s). +``` + +Dynamic resolution typically lands at: + +- **Read context** (GET list / GET item): `current_user_can('read_private_s')` or `current_user_can('read_', $id)`. +- **Write context** (POST / PUT / DELETE): `current_user_can('edit_s')`, `current_user_can('edit_others_s')`, or `current_user_can('delete_s', $id)`. + +The two often differ — post-type-backed plugins routinely have distinct read +and write caps. + +### How to represent in the audit + +Use the structured `{read, write}` form from `audit-schema.md`: + +```yaml +capability_gate: + read: read_private_pages + write: edit_others_pages + confirmed: true + verified_at: "custom_post_type capability_type='page' → core map_meta_cap (wp-includes/post.php) → primitive page caps" +``` + +In each ability's `permission` block, spell out both calls: + +```yaml +permission: + callback: get_items_permissions_check + resolves_to: "WP_REST_Posts_Controller::get_items_permissions_check (inherited) → current_user_can('read_private_pages')" + confirmed: true +``` + +Example A — generic plugin shadowing core Pages caps. A custom post type +registered with `capability_type='page'` inherits the Pages cap map, so +reads gate on `read_private_pages` and writes gate on `edit_others_pages`. + +Example B — WooCommerce-style sidebar. WooCommerce's `shop_subscription` is +registered with `capability_type='shop_order'`, so reads gate on +`read_private_shop_orders` and writes gate on `edit_shop_orders`. +Mechanically identical to Example A; the cap names are project-specific. +WooCommerce also exposes a helper `wc_rest_check_post_permissions()` that +wraps the same core machinery — the helper is convenience; the underlying +mechanism is core's `map_meta_cap()`. + +## Compound-string form (accepted, not preferred) + +Some earlier audits encoded compound gates as a single string with a `/` +separator: + +```yaml +capability_gate: read_private_pages / edit_others_pages +``` + +This is accepted for backwards compatibility, but: + +- Downstream consumers have to heuristically split on `/`. +- YAML comments after the string are silently dropped by strict parsers, so + provenance gets lost. +- The `{read, write}` object form is machine-parseable and carries + `confirmed` and `verified_at` in-band. + +Prefer the structured form for any new audit. + +## Procedure — trace the permission source for each proposed behavior + +The ability's permission should match the plugin's intended gate for the +proposed *behavior*, not necessarily the REST route. Often the REST +controller's `permission_callback` is the right source of truth, but in +some plugins the canonical permission lives elsewhere — an admin-action +handler with its own `check_admin_referer` + `current_user_can` block, a +service / helper method that performs the check before doing the work, a +domain-policy / authorization layer, or a post-type cap shadow resolved +through core's `map_meta_cap`. The audit should preserve where the +permission canonically lives so the implementer doesn't silently drift +to whichever source the REST layer happens to expose. + +For each proposed ability, walk the chain once: + +1. Identify the *behavior* the ability surfaces, then locate where the + plugin enforces the cap for that behavior. Check the REST controller's + `permission_callback` first; if the REST callback is `'__return_true'`, + delegates entirely, or doesn't match the behavior's intended gate, + look for the canonical source in an admin handler, a shared service + method, a domain-policy class, or a post-type cap map. +2. Record where the gate lives in the ability's `permission.source` field + per `audit-schema.md`: one of `rest_controller`, `admin_action`, + `service`, `domain_policy`, `post_type_map`, `none`. Default + `rest_controller`; pick another value when the canonical source is + elsewhere. +3. Determine whether the gate is Mechanism A (local method, single cap) + or Mechanism B (inherited, post-type-backed, dynamic). +4. Resolve to the actual `current_user_can()` call(s). For Mechanism B, + resolve BOTH read and write if the ability crosses contexts. +5. Record in the ability's `permission.resolves_to` field verbatim — the + string should read as an actual trace, not a best-guess summary. +6. Add a risk note when the canonical permission source diverges from + the REST controller's callback: the ability's `permission_callback` + must consult the canonical source (or replicate its check), not + copy the REST callback by reflex. +7. If every behavior in the plugin resolves to the same cap (or same + `{read, write}` pair) at the same source, hoist it into the top-level + `capability_gate`. If any behavior diverges in cap OR in source, + record the divergence in "Notes and Surprises". + +## Common pitfall — `permission_callback => '__return_true'` + +Zero-arg public endpoints sometimes declare `permission_callback => +'__return_true'` at the REST layer (e.g. status lookups, enumerated lists +that are safe to expose). The audit still needs a gate: + +- Record the REST-layer value as-is (`resolves_to: + "__return_true (public)"`) so the auditor isn't hiding reality. +- Add a risk note: the **ability** registration must NOT copy + `'__return_true'` — the ability's own `permission_callback` must match + the plugin's intended user gate (e.g. `manage_options`, `edit_pages`, or + whatever your plugin uses). The ability layer is the agent-facing surface + and needs that gate even when the underlying REST route is public. diff --git a/.agents/skills/wp-abilities-audit/references/controller-enumeration.md b/.agents/skills/wp-abilities-audit/references/controller-enumeration.md new file mode 100644 index 0000000..b63340f --- /dev/null +++ b/.agents/skills/wp-abilities-audit/references/controller-enumeration.md @@ -0,0 +1,116 @@ +# Controller Enumeration + +How to produce an exhaustive list of a plugin's REST controllers — the first +step of every audit. Plugin family classification is handled separately by +`wp-project-triage`; this reference covers the mechanics of finding controller +classes inside whatever layout the plugin happens to use. + +## Two enumeration paths + +Observed across the plugins audited to date, there are exactly two paths that +together cover every layout seen in the wild: + +| Path | When it works | How it works | +|---|---|---| +| **Glob** | Plugins that follow the standard `includes/admin/class-*-rest-*-controller.php` layout (WooCommerce core extensions, classic WooPayments). | Fast, deterministic, easy to script. Returns a complete list in one shell call. | +| **Grep** | Any non-standard layout — `includes/api/`, `includes/rest-api/`, `src/rest/`, monorepo package directories, or anything else. | Universal fallback: grep every PHP file under the plugin root for `register_rest_route(` call sites, then collect the enclosing class for each hit. | + +### Default order + +1. **Try glob first** — it's faster and produces a cleaner inventory. +2. **Fall back to grep** if glob returns zero hits (or clearly undercounts + against what you see in the plugin's public documentation / admin UI). + +Running both and de-duplicating is legal; it catches monorepos that have +*some* controllers under the standard layout and *others* under a package +directory. + +## Glob — standard layout + +```bash +# From the plugin root: +ls includes/admin/class-*-rest-*-controller.php 2>/dev/null +ls includes/reports/class-*-rest-*-controller.php 2>/dev/null +``` + +What you'll see in repos that match this convention: + +- WooPayments (`Automattic/woocommerce-payments`) — every controller under + `includes/admin/class-wc-rest-payments-*-controller.php` plus some under + `includes/reports/`. +- WooCommerce core's internal REST controllers use the same pattern. + +If glob returns 5+ hits, it's almost always the complete inventory. If it +returns 0-2, fall through to grep. + +## Grep — universal fallback + +```bash +# From the plugin root: +grep -rn --include='*.php' 'register_rest_route(' . +``` + +For each hit: + +1. Open the file. +2. Walk up to the enclosing class declaration. +3. Record `(class, file, route, callback, permission_callback)`. + +This path matters because it's the only one that finds controllers in +non-standard locations: + +- **WooCommerce Subscriptions** — controllers live under `includes/api/` and + `includes/api/legacy/`. The standard WooPayments glob returns zero; grep is + mandatory. +- **Jetpack Forms** (and most Jetpack packages) — controllers live under + `projects/packages//src/` with no conventional filename. Grep is + again mandatory. +- **Custom plugin layouts** — anything with `src/Rest/`, `lib/rest/`, + `api/v1/`, etc. Grep catches them all. + +## Inherited routes + +A controller can extend a base class in a different repo — typically the +parent plugin (for extensions built on top of another plugin) or WordPress +core itself (for plugins extending `WP_REST_Posts_Controller` or other core +REST bases). The `parent::register_routes()` dispatch appears in the +extending plugin's source, but the literal `register_rest_route(` call lives +in the parent. WooCommerce extensions extending +`WC_REST_Orders_Controller`, plugins built on Jetpack package REST classes, +and CPT plugins inheriting from `WP_REST_Posts_Controller` all hit this +pattern. + +Handling: + +- Record the route on the child class (that's where the plugin's REST surface + actually exposes it). +- Set `backing.route_registration_line: null` and + `backing.callback_line: null` in the audit schema. +- Add `backing.inherited_from: ""` so downstream skills can tell + the inheritance case from a plain missing line number. +- Consider running grep against the parent repo too when you need to confirm + the callback's request handling — inherited callbacks behave as whatever + the parent defines, not what the plugin repo documents. + +See `audit-schema.md` for the exact field shapes. + +## Exhaustiveness is the goal + +The "Controller Inventory" table in the audit doc must list every controller +the enumeration found — not just ones backing proposed abilities. A reviewer +asking "why isn't controller X in the MVP?" should be able to point at the +inventory and see the explicit answer (usually: "excluded from MVP because…" +or "surfaced as a gap because…"). + +If your inventory has 3 entries and the plugin clearly exposes more, either +the enumeration is incomplete (re-run grep with broader patterns) or you're +filtering the inventory instead of the proposal list. Fix the inventory +first; filter after. + +## Escalation + +If neither glob nor grep produces a complete inventory — for example a +plugin that registers routes dynamically from config or via a factory that +does not contain a literal `register_rest_route(` string — document the +enumeration gap in "Notes and Surprises", and extend this reference with the +new pattern once understood. diff --git a/.agents/skills/wp-block-development/SKILL.md b/.agents/skills/wp-block-development/SKILL.md new file mode 100644 index 0000000..110e9d2 --- /dev/null +++ b/.agents/skills/wp-block-development/SKILL.md @@ -0,0 +1,174 @@ +--- +name: wp-block-development +description: "Use when developing WordPress (Gutenberg) blocks: block.json metadata, register_block_type(_from_metadata), attributes/serialization, supports, dynamic rendering (render.php/render_callback), deprecations/migrations, viewScript vs viewScriptModule, and @wordpress/scripts/@wordpress/create-block build and test workflows." +compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+). Filesystem-based agent with bash + node. Some workflows require WP-CLI." +--- + +# WP Block Development + +## When to use + +Use this skill for block work such as: + +- creating a new block, or updating an existing one +- changing `block.json` (scripts/styles/supports/attributes/render/viewScriptModule) +- fixing “block invalid / not saving / attributes not persisting” +- adding dynamic rendering (`render.php` / `render_callback`) +- block deprecations and migrations (`deprecated` versions) +- build tooling for blocks (`@wordpress/scripts`, `@wordpress/create-block`, `wp-env`) + +## Inputs required + +- Repo root and target (plugin vs theme vs full site). +- The block name/namespace and where it lives (path to `block.json` if known). +- Target WordPress version range (especially if using modules / `viewScriptModule`). + +## Procedure + +### 0) Triage and locate blocks + +1. Run triage: + - `node skills/wp-project-triage/scripts/detect_wp_project.mjs` +2. List blocks (deterministic scan): + - `node skills/wp-block-development/scripts/list_blocks.mjs` +3. Identify the block root (directory containing `block.json`) you’re changing. + +If this repo is a full site (`wp-content/` present), be explicit about *which* plugin/theme contains the block. + +### 1) Create a new block (if needed) + +If you are creating a new block, prefer scaffolding rather than hand-rolling structure: + +- Use `@wordpress/create-block` to scaffold a modern block/plugin setup. +- If you need Interactivity API from day 1, use the interactive template. + +Read: +- `references/creating-new-blocks.md` + +After scaffolding: + +1. Re-run the block list script and confirm the new block root. +2. Continue with the remaining steps (model choice, metadata, registration, serialization). + +### 2) Ensure apiVersion 3 (WordPress 6.9+) + +WordPress 6.9 enforces `apiVersion: 3` in the block.json schema. Blocks with apiVersion 2 or lower trigger console warnings when `SCRIPT_DEBUG` is enabled. + +**Why this matters:** +- WordPress 7.0 will run the post editor in an iframe regardless of block apiVersion. +- apiVersion 3 ensures your block works correctly inside the iframed editor (style isolation, viewport units, media queries). + +**Migration:** Changing from version 2 to 3 is usually as simple as updating the `apiVersion` field in `block.json`. However: +- Test in a local environment with the iframe editor enabled. +- Ensure any style handles are included in `block.json` (styles missing from the iframe won't apply). +- Third-party scripts attached to a specific `window` may have scoping issues. + +Read: +- `references/block-json.md` (apiVersion and schema details) + +### 3) Pick the right block model + +- **Static block** (markup saved into post content): implement `save()`; keep attributes serialization stable. +- **Dynamic block** (server-rendered): use `render` in `block.json` (or `render_callback` in PHP) and keep `save()` minimal or `null`. +- **Interactive frontend behavior**: + - Prefer `viewScriptModule` for modern module-based view scripts where supported. + - If you're working primarily on `data-wp-*` directives or stores, also use `wp-interactivity-api`. + +### 4) Update `block.json` safely + +Make changes in the block’s `block.json`, then confirm registration matches metadata. + +For field-by-field guidance, read: +- `references/block-json.md` + +Common pitfalls: + +- changing `name` breaks compatibility (treat it as stable API) +- changing saved markup without adding `deprecated` causes “Invalid block” +- adding attributes without defining source/serialization correctly causes “attribute not saving” + +### 5) Register the block (server-side preferred) + +Prefer PHP registration using metadata, especially when: + +- you need dynamic rendering +- you need translations (`wp_set_script_translations`) +- you need conditional asset loading + +Read and apply: +- `references/registration.md` + +### 6) Implement edit/save/render patterns + +Follow wrapper attribute best practices: + +- Editor: `useBlockProps()` +- Static save: `useBlockProps.save()` +- Dynamic render (PHP): `get_block_wrapper_attributes()` + +Read: +- `references/supports-and-wrappers.md` +- `references/dynamic-rendering.md` (if dynamic) + +### 7) Inner blocks (block composition) + +If your block is a “container” that nests other blocks, treat Inner Blocks as a first-class feature: + +- Use `useInnerBlocksProps()` to integrate inner blocks with wrapper props. +- Keep migrations in mind if you change inner markup. + +Read: +- `references/inner-blocks.md` + +### 8) Attributes and serialization + +Before changing attributes: + +- confirm where the attribute value lives (comment delimiter vs HTML vs context) +- avoid the deprecated `meta` attribute source + +Read: +- `references/attributes-and-serialization.md` + +### 9) Migrations and deprecations (avoid "Invalid block") + +If you change saved markup or attributes: + +1. Add a `deprecated` entry (newest → oldest). +2. Provide `save` for old versions and an optional `migrate` to normalize attributes. + +Read: +- `references/deprecations.md` + +### 10) Tooling and verification commands + +Prefer whatever the repo already uses: + +- `@wordpress/scripts` (common) → run existing npm scripts +- `wp-env` (common) → use for local WP + E2E + +Read: +- `references/tooling-and-testing.md` + +## Verification + +- Block appears in inserter and inserts successfully. +- Saving + reloading does not create “Invalid block”. +- Frontend output matches expectations (static: saved markup; dynamic: server output). +- Assets load where expected (editor vs frontend). +- Run the repo’s lint/build/tests that triage recommends. + +## Failure modes / debugging + +If something fails, start here: + +- `references/debugging.md` (common failures + fastest checks) +- `references/attributes-and-serialization.md` (attributes not saving) +- `references/deprecations.md` (invalid block after change) + +## Escalation + +If you’re uncertain about upstream behavior/version support, consult canonical docs first: + +- WordPress Developer Resources (Block Editor Handbook, Theme Handbook, Plugin Handbook) +- Gutenberg repo docs for bleeding-edge behaviors diff --git a/.agents/skills/wp-block-development/references/attributes-and-serialization.md b/.agents/skills/wp-block-development/references/attributes-and-serialization.md new file mode 100644 index 0000000..08fd2a8 --- /dev/null +++ b/.agents/skills/wp-block-development/references/attributes-and-serialization.md @@ -0,0 +1,22 @@ +# Attributes and serialization + +Use this file when attributes aren’t saving, content becomes “Invalid block”, or you’re changing markup. + +## How attributes persist + +Attributes can come from: + +- the comment delimiter JSON (common and stable) +- the block’s saved HTML (from tags/attributes) +- context + +Read the canonical guide for supported `source`/`selector`/`attribute` patterns: + +- https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/ + +## Common pitfalls + +- Changing saved HTML without a `deprecated` version breaks existing posts. +- Using the `meta` attribute source (deprecated) causes long-term pain; avoid it. +- Choosing brittle selectors leads to attributes “not found” when markup changes slightly. + diff --git a/.agents/skills/wp-block-development/references/block-json.md b/.agents/skills/wp-block-development/references/block-json.md new file mode 100644 index 0000000..9cfe967 --- /dev/null +++ b/.agents/skills/wp-block-development/references/block-json.md @@ -0,0 +1,49 @@ +# `block.json` (metadata) guidance + +Use this file when you’re editing `block.json` fields or choosing between script/styles fields. + +## Practical rules + +- Treat `name` as stable API (renaming breaks existing content). +- Prefer adding new functionality without changing saved markup; if markup must change, add a `deprecated` version. +- Keep assets scoped: editor assets should not ship to frontend unless needed. + +## API version + schema + +**WordPress 6.9+ requires apiVersion 3.** The block.json schema now only validates blocks with `apiVersion: 3`. Older versions (1 or 2) trigger console warnings when `SCRIPT_DEBUG` is enabled. + +**Why apiVersion 3 matters:** +- The post editor will be iframed if all registered blocks have apiVersion 3+. +- WordPress 7.0 will always use the iframe editor regardless of apiVersion. +- Benefits: style isolation (admin CSS won't affect editor content), correct viewport units (vw, vh), native media queries. + +**Migration checklist:** +1. Update `apiVersion` to `3` in block.json. +2. Ensure all style handles are declared in block.json (styles not included won't load in the iframe). +3. Test blocks that rely on third-party scripts (window scoping may differ). +4. Add a `$schema` to improve editor tooling and validation. + +References: + +- Block metadata: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/ +- Block API versions: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/ +- Iframe migration guide: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/block-migration-for-iframe-editor-compatibility/ +- Block schema index: https://schemas.wp.org/ + +## Modern asset fields to know + +This is not a full schema; it’s a “what matters in practice” list: + +- `editorScript` / `editorStyle`: editor-only assets. +- `script` / `style`: shared assets. +- `viewScript` / `viewStyle`: frontend view assets. +- `viewScriptModule`: module-based frontend scripts (newer WP). +- `render`: points to a PHP render file for dynamic blocks (newer WP). + +## Helpful upstream references + +- Block metadata reference (block.json): + - https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/ +- Block.json schema (editor tooling): + - https://schemas.wp.org/trunk/block.json + diff --git a/.agents/skills/wp-block-development/references/creating-new-blocks.md b/.agents/skills/wp-block-development/references/creating-new-blocks.md new file mode 100644 index 0000000..f9d8f74 --- /dev/null +++ b/.agents/skills/wp-block-development/references/creating-new-blocks.md @@ -0,0 +1,46 @@ +# Creating new blocks (scaffolding) + +Use this file when you are creating a new block (or a new block plugin) from scratch. + +## Preferred path: `@wordpress/create-block` + +`@wordpress/create-block` scaffolds a modern block setup that tends to track current best practices. + +Typical options to decide up front: + +- TypeScript vs JavaScript +- Static vs dynamic (`render.php` / server rendering) +- Whether the block should be interactive on the frontend + +Canonical docs: + +- https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/ + +## “Most up-to-date” interactive blocks + +For a modern interactive block, prefer the official Interactivity API template: + +- Template: `@wordpress/create-block-interactive-template` + +This template is designed to integrate: + +- Interactivity API directives (`data-wp-*`) +- module-based view scripts (`viewScriptModule`) +- server rendering (`render.php`) + +References: + +- https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/ +- https://make.wordpress.org/core/2024/03/04/a-first-look-at-the-interactivity-api-in-wordpress-6-5/ + +## Manual fallback (when scaffolding is not available) + +If you cannot run `create-block` (no Node tooling or restricted network): + +1. Create a plugin or theme location that will register the block. +2. Create a block folder with a valid `block.json`. +3. Register via `register_block_type_from_metadata()` in PHP. +4. Add editor JS and (optionally) frontend view assets. + +Then follow the rest of `wp-block-development` for metadata, registration, and serialization. + diff --git a/.agents/skills/wp-block-development/references/debugging.md b/.agents/skills/wp-block-development/references/debugging.md new file mode 100644 index 0000000..c650407 --- /dev/null +++ b/.agents/skills/wp-block-development/references/debugging.md @@ -0,0 +1,36 @@ +# Debugging quick routes + +## Block doesn’t appear in inserter + +- Confirm `block.json` `name` is valid and the block is registered. +- Confirm build output exists and scripts are enqueued. +- If using PHP registration, confirm `register_block_type_from_metadata()` runs (wrong hook/file not loaded is common). + +## “This block contains unexpected or invalid content” + +- You changed saved markup or attribute parsing. +- Add `deprecated` versions and a migration path. +- Reproduce with an old post containing the previous markup. + +## Attributes not saving + +- Confirm attribute definition matches actual markup. +- If the value is in delimiter JSON, avoid brittle selectors. +- Avoid `meta` attribute source (deprecated). + +## Console warnings about apiVersion (WordPress 6.9+) + +If you see "The block 'namespace/block' is registered with API version 2 or lower": + +- Update `apiVersion` to `3` in block.json. +- This warning only appears when `SCRIPT_DEBUG` is true. +- WordPress 7.0 will require apiVersion 3 for proper iframe editor support. + +## Styles not applying in editor (apiVersion 3 / iframe) + +If styles work on frontend but not in the editor: + +- Ensure style handles are declared in block.json (`editorStyle`, `style`). +- Styles not included in block.json won't load inside the iframed editor. +- Check for Dashicons or other dependencies that need explicit inclusion. + diff --git a/.agents/skills/wp-block-development/references/deprecations.md b/.agents/skills/wp-block-development/references/deprecations.md new file mode 100644 index 0000000..582e7c4 --- /dev/null +++ b/.agents/skills/wp-block-development/references/deprecations.md @@ -0,0 +1,24 @@ +# Deprecations and migrations + +Use this file when you must change saved markup or attribute shapes without breaking existing content. + +## `deprecated` basics + +Block deprecations are handled in JS block registration. + +- Add older implementations to `deprecated` (newest → oldest). +- Each deprecated entry can include: + - `attributes` + - `supports` + - `save` + - `migrate` + +Upstream reference: + +- https://developer.wordpress.org/block-editor/reference-guides/block-api/block-deprecation/ + +## Practical guardrails + +- Keep fixtures: store example content for each deprecated version. +- When in doubt, add a migration path rather than silently changing selectors. + diff --git a/.agents/skills/wp-block-development/references/dynamic-rendering.md b/.agents/skills/wp-block-development/references/dynamic-rendering.md new file mode 100644 index 0000000..ee6aa94 --- /dev/null +++ b/.agents/skills/wp-block-development/references/dynamic-rendering.md @@ -0,0 +1,23 @@ +# Dynamic blocks (server rendering) + +Use this file when converting a block to dynamic, or debugging frontend output mismatch. + +## Choose the mechanism + +- Prefer `render` in `block.json` (dynamic render file). +- Alternative: pass `render_callback` when registering the block in PHP. + +## Wrapper attributes + +In PHP render output, always use: + +- `get_block_wrapper_attributes()` + +This preserves support-generated classes/styles. + +## Practical checklist + +- Ensure PHP file exists and is reachable from the block root. +- Ensure registration runs on every request (not only in admin). +- Keep `save()` empty or `null` for fully dynamic output, unless you intentionally save fallback markup. + diff --git a/.agents/skills/wp-block-development/references/inner-blocks.md b/.agents/skills/wp-block-development/references/inner-blocks.md new file mode 100644 index 0000000..e150ed9 --- /dev/null +++ b/.agents/skills/wp-block-development/references/inner-blocks.md @@ -0,0 +1,25 @@ +# Inner Blocks (nested blocks) + +Use this file when your block contains other blocks (container blocks). + +## Canonical references + +- Nested blocks guide: https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/nested-blocks-inner-blocks/ +- `@wordpress/block-editor` package: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/ +- Block supports: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/ + +## Practical patterns + +- Editor: + - Use `useInnerBlocksProps( useBlockProps(), { ... } )` to combine wrapper props with inner blocks. + - Use templates/allowed blocks only when you have a clear UX reason (too strict is frustrating). +- Save: + - Use `useInnerBlocksProps.save( useBlockProps.save(), { ... } )` if you need wrapper props. + - Output nested content via `` when appropriate. + +## Common pitfalls + +- Only one `InnerBlocks` should exist per block. +- Changing the wrapper structure that contains inner blocks can invalidate existing content; consider deprecations/migrations. +- If you need to constrain allowed blocks, prefer doing it intentionally and documenting why. + diff --git a/.agents/skills/wp-block-development/references/registration.md b/.agents/skills/wp-block-development/references/registration.md new file mode 100644 index 0000000..f878999 --- /dev/null +++ b/.agents/skills/wp-block-development/references/registration.md @@ -0,0 +1,30 @@ +# Registration patterns (PHP-first) + +Use this file when you need to register blocks robustly across repo types (plugin/theme/site). + +## Prefer metadata registration + +Prefer: + +- `register_block_type_from_metadata( $path_to_block_dir, $args = [] )` + +Why: + +- keeps metadata authoritative (`block.json`) +- supports dynamic render (`render`) and other metadata-driven fields +- enables cleaner asset handling + +Upstream reference: + +- https://developer.wordpress.org/reference/functions/register_block_type_from_metadata/ + +## Where to register + +- Plugins: register on `init` in the main plugin bootstrap or a dedicated loader. +- Themes: register on `init` (or `after_setup_theme` if you need theme supports first), but keep it predictable. + +## Dynamic render mapping + +If `block.json` includes `render`, ensure the file exists relative to the block root. +Inside the render file, use `get_block_wrapper_attributes()` for wrapper attributes. + diff --git a/.agents/skills/wp-block-development/references/supports-and-wrappers.md b/.agents/skills/wp-block-development/references/supports-and-wrappers.md new file mode 100644 index 0000000..b8e6dcf --- /dev/null +++ b/.agents/skills/wp-block-development/references/supports-and-wrappers.md @@ -0,0 +1,18 @@ +# Supports and wrapper attributes + +Use this file when changing `supports` or when your block wrapper styling behaves unexpectedly. + +## Required patterns + +- In `edit()`, use `useBlockProps()`. +- In `save()`, use `useBlockProps.save()`. + +If the block is dynamic (PHP render), use: + +- `get_block_wrapper_attributes()` + +Upstream reference: + +- https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/ +- https://developer.wordpress.org/reference/functions/get_block_wrapper_attributes/ + diff --git a/.agents/skills/wp-block-development/references/tooling-and-testing.md b/.agents/skills/wp-block-development/references/tooling-and-testing.md new file mode 100644 index 0000000..a8f2a2b --- /dev/null +++ b/.agents/skills/wp-block-development/references/tooling-and-testing.md @@ -0,0 +1,21 @@ +# Tooling and testing + +Use this file when deciding what commands to run and what “good verification” looks like. + +## Common toolchains + +- `@wordpress/scripts` for build/lint/test: + - https://developer.wordpress.org/block-editor/reference-guides/packages/packages-scripts/ +- `@wordpress/create-block` to scaffold new blocks: + - https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/ +- Interactivity API template for `create-block`: + - https://www.npmjs.com/package/@wordpress/create-block-interactive-template +- `@wordpress/env` (wp-env) for local WordPress environments: + - https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/ + +## Verification checklist + +- `npm run build` (or repo equivalent) succeeds. +- JS lint passes (repo-specific). +- E2E tests pass if present. +- Manual: insert block, save post, reload editor, confirm no “Invalid block”. diff --git a/.agents/skills/wp-block-development/scripts/list_blocks.mjs b/.agents/skills/wp-block-development/scripts/list_blocks.mjs new file mode 100644 index 0000000..205f541 --- /dev/null +++ b/.agents/skills/wp-block-development/scripts/list_blocks.mjs @@ -0,0 +1,121 @@ +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_IGNORES = new Set([ + ".git", + "node_modules", + "vendor", + "dist", + "build", + "coverage", + ".next", + ".turbo", +]); + +function statSafe(p) { + try { + return fs.statSync(p); + } catch { + return null; + } +} + +function existsDir(p) { + const st = statSafe(p); + return Boolean(st && st.isDirectory()); +} + +function readJsonSafe(p) { + try { + return JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + return null; + } +} + +function findFilesRecursive(repoRoot, predicate, { maxFiles = 6000, maxDepth = 10 } = {}) { + const results = []; + const queue = [{ dir: repoRoot, depth: 0 }]; + let visited = 0; + + while (queue.length > 0) { + const { dir, depth } = queue.shift(); + if (depth > maxDepth) continue; + + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const ent of entries) { + const fullPath = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (DEFAULT_IGNORES.has(ent.name)) continue; + queue.push({ dir: fullPath, depth: depth + 1 }); + continue; + } + if (!ent.isFile()) continue; + + visited += 1; + if (visited > maxFiles) return { results, truncated: true }; + if (predicate(fullPath)) results.push(fullPath); + } + } + + return { results, truncated: false }; +} + +function summarizeBlockJson(repoRoot, blockJsonPath) { + const json = readJsonSafe(blockJsonPath); + if (!json) { + return { + path: path.relative(repoRoot, blockJsonPath), + error: "invalid-json", + }; + } + + const rel = path.relative(repoRoot, blockJsonPath); + const blockRoot = path.dirname(rel); + + return { + path: rel, + blockRoot, + name: typeof json?.name === "string" ? json.name : null, + title: typeof json?.title === "string" ? json.title : null, + apiVersion: typeof json?.apiVersion === "number" ? json.apiVersion : null, + render: typeof json?.render === "string" ? json.render : null, + viewScript: json?.viewScript ?? null, + viewScriptModule: json?.viewScriptModule ?? null, + editorScript: json?.editorScript ?? null, + script: json?.script ?? null, + style: json?.style ?? null, + editorStyle: json?.editorStyle ?? null, + attributes: json?.attributes ? Object.keys(json.attributes).slice(0, 50) : [], + }; +} + +function main() { + const repoRoot = process.cwd(); + + const { results: blockJsonFiles, truncated } = findFilesRecursive(repoRoot, (p) => path.basename(p) === "block.json", { + maxFiles: 8000, + maxDepth: 12, + }); + + const blocks = blockJsonFiles.map((p) => summarizeBlockJson(repoRoot, p)); + + const report = { + tool: { name: "list_blocks", version: "0.1.0" }, + repoRoot, + truncated, + count: blocks.length, + blocks, + }; + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main(); + diff --git a/.agents/skills/wp-plugin-development/SKILL.md b/.agents/skills/wp-plugin-development/SKILL.md new file mode 100644 index 0000000..4456de5 --- /dev/null +++ b/.agents/skills/wp-plugin-development/SKILL.md @@ -0,0 +1,112 @@ +--- +name: wp-plugin-development +description: "Use when developing WordPress plugins: architecture and hooks, activation/deactivation/uninstall, admin UI and Settings API, data storage, cron/tasks, security (nonces/capabilities/sanitization/escaping), and release packaging." +compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+). Filesystem-based agent with bash + node. Some workflows require WP-CLI." +--- + +# WP Plugin Development + +## When to use + +Use this skill for plugin work such as: + +- creating or refactoring plugin structure (bootstrap, includes, namespaces/classes) +- adding hooks/actions/filters +- activation/deactivation/uninstall behavior and migrations +- adding settings pages / options / admin UI (Settings API) +- security fixes (nonces, capabilities, sanitization/escaping, SQL safety) +- packaging a release (build artifacts, readme, assets) + +## Inputs required + +- Repo root + target plugin(s) (path to plugin main file if known). +- Where this plugin runs: single site vs multisite; WP.com conventions if applicable. +- Target WordPress + PHP versions (affects available APIs and placeholder support in `$wpdb->prepare()`). + +## Procedure + +### 0) Triage and locate plugin entrypoints + +1. Run triage: + - `node skills/wp-project-triage/scripts/detect_wp_project.mjs` +2. Detect plugin headers (deterministic scan): + - `node skills/wp-plugin-development/scripts/detect_plugins.mjs` + +If this is a full site repo, pick the specific plugin under `wp-content/plugins/` or `mu-plugins/` before changing code. + +### 1) Follow a predictable architecture + +Guidelines: + +- Keep a single bootstrap (main plugin file with header). +- Avoid heavy side effects at file load time; load on hooks. +- Prefer a dedicated loader/class to register hooks. +- Keep admin-only code behind `is_admin()` (or admin hooks) to reduce frontend overhead. + +See: +- `references/structure.md` + +### 2) Hooks and lifecycle (activation/deactivation/uninstall) + +Activation hooks are fragile; follow guardrails: + +- register activation/deactivation hooks at top-level, not inside other hooks +- flush rewrite rules only when needed and only after registering CPTs/rules +- uninstall should be explicit and safe (`uninstall.php` or `register_uninstall_hook`) + +See: +- `references/lifecycle.md` + +### 3) Settings and admin UI (Settings API) + +Prefer Settings API for options: + +- `register_setting()`, `add_settings_section()`, `add_settings_field()` +- sanitize via `sanitize_callback` + +See: +- `references/settings-api.md` + +### 4) Security baseline (always) + +Before shipping: + +- Validate/sanitize input early; escape output late. +- Use nonces to prevent CSRF *and* capability checks for authorization. +- Avoid directly trusting `$_POST` / `$_GET`; use `wp_unslash()` and specific keys. +- Use `$wpdb->prepare()` for SQL; avoid building SQL with string concatenation. + +See: +- `references/security.md` + +### 5) Data storage, cron, migrations (if needed) + +- Prefer options for small config; custom tables only if necessary. +- For cron tasks, ensure idempotency and provide manual run paths (WP-CLI or admin). +- For schema changes, write upgrade routines and store schema version. + +See: +- `references/data-and-cron.md` + +## Verification + +- Plugin activates with no fatals/notices. +- Settings save and read correctly (capability + nonce enforced). +- Uninstall removes intended data (and nothing else). +- Run repo lint/tests (PHPUnit/PHPCS if present) and any JS build steps if the plugin ships assets. + +## Failure modes / debugging + +- Activation hook not firing: + - hook registered incorrectly (not in main file scope), wrong main file path, or plugin is network-activated +- Settings not saving: + - settings not registered, wrong option group, missing capability, nonce failure +- Security regressions: + - nonce present but missing capability checks; or sanitized input not escaped on output + +See: +- `references/debugging.md` + +## Escalation + +For canonical detail, consult the Plugin Handbook and security guidelines before inventing patterns. diff --git a/.agents/skills/wp-plugin-development/references/data-and-cron.md b/.agents/skills/wp-plugin-development/references/data-and-cron.md new file mode 100644 index 0000000..193e25c --- /dev/null +++ b/.agents/skills/wp-plugin-development/references/data-and-cron.md @@ -0,0 +1,19 @@ +# Data storage, cron, and upgrades + +Use this file when adding persistent storage, background jobs, or upgrade routines. + +## Data storage + +- Prefer Options API for small config/state. +- Use custom tables only when needed; store schema version and provide upgrade paths. + +## Cron + +- Ensure tasks are idempotent (may run late or multiple times). +- Provide a manual trigger path for debugging (WP-CLI or admin-only action). + +## Database safety note + +If using `$wpdb->prepare()`, avoid building queries with concatenated user input. +Recent WordPress versions support identifier placeholders (`%i`) but you must not assume it exists without checking capabilities or target versions. + diff --git a/.agents/skills/wp-plugin-development/references/debugging.md b/.agents/skills/wp-plugin-development/references/debugging.md new file mode 100644 index 0000000..af78336 --- /dev/null +++ b/.agents/skills/wp-plugin-development/references/debugging.md @@ -0,0 +1,19 @@ +# Debugging quick routes + +## Plugin doesn’t load / fatal errors + +- Confirm correct plugin main file and header. +- Check PHP error logs and `WP_DEBUG_LOG`. +- If the repo is a site repo, confirm you edited the correct plugin under `wp-content/plugins/`. + +## Activation hook surprises + +- Hooks must be registered at top-level. +- Activation runs in a special context; avoid assuming other hooks already ran. + +## Settings not saving + +- Confirm `register_setting()` is called. +- Confirm the option group matches the form. +- Confirm capability checks and nonces. + diff --git a/.agents/skills/wp-plugin-development/references/lifecycle.md b/.agents/skills/wp-plugin-development/references/lifecycle.md new file mode 100644 index 0000000..ebf0837 --- /dev/null +++ b/.agents/skills/wp-plugin-development/references/lifecycle.md @@ -0,0 +1,33 @@ +# Activation, deactivation, uninstall + +Use this file for lifecycle changes and data cleanup. + +## Activation / deactivation hooks + +- `register_activation_hook( __FILE__, 'callback' )` +- `register_deactivation_hook( __FILE__, 'callback' )` + +Guardrails: + +- These hooks must be registered at top-level (not inside other hooks). +- If you flush rewrite rules, ensure rules are registered first (often via a shared function called both on `init` and activation). + +Upstream reference: + +- https://developer.wordpress.org/plugins/plugin-basics/activation-deactivation-hooks/ + +## Uninstall + +Preferred approaches: + +- `uninstall.php` (runs only on uninstall) +- `register_uninstall_hook()` + +Guardrails: + +- Check `WP_UNINSTALL_PLUGIN` before running destructive cleanup. + +Upstream reference: + +- https://developer.wordpress.org/plugins/plugin-basics/uninstall-methods/ + diff --git a/.agents/skills/wp-plugin-development/references/security.md b/.agents/skills/wp-plugin-development/references/security.md new file mode 100644 index 0000000..c3c68fe --- /dev/null +++ b/.agents/skills/wp-plugin-development/references/security.md @@ -0,0 +1,29 @@ +# Security guardrails (plugin work) + +Use this file when making security fixes or when handling any input/output. + +## Nonces + permissions + +- Nonces help prevent CSRF, not authorization. +- Always pair nonces with capability checks (`current_user_can()` or a more specific capability). + +Upstream reference: + +- https://developer.wordpress.org/apis/security/nonces/ + +## Sanitization and escaping + +Golden rule: + +- sanitize/validate on input, escape on output. + +Practical rules: + +- never process the entire `$_POST` / `$_GET` array; read explicit keys +- use `wp_unslash()` before sanitizing when needed +- use prepared statements for SQL; avoid interpolating user input into queries + +Common review guidance: + +- https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/ + diff --git a/.agents/skills/wp-plugin-development/references/settings-api.md b/.agents/skills/wp-plugin-development/references/settings-api.md new file mode 100644 index 0000000..0a07970 --- /dev/null +++ b/.agents/skills/wp-plugin-development/references/settings-api.md @@ -0,0 +1,22 @@ +# Settings API (admin options) + +Use this file when adding settings pages or storing user-configurable options. + +Core APIs: + +- `register_setting()` +- `add_settings_section()` +- `add_settings_field()` + +Upstream references: + +- Settings API overview: https://developer.wordpress.org/plugins/settings/settings-api/ +- Register settings: https://developer.wordpress.org/plugins/settings/registration/ +- Add settings fields: https://developer.wordpress.org/plugins/settings/settings-fields/ + +Practical guardrails: + +- Use `sanitize_callback` to validate/sanitize data. +- Use capability checks (commonly `manage_options`) for settings screens and saves. +- Escape values on output (`esc_attr`, `esc_html`, etc.). + diff --git a/.agents/skills/wp-plugin-development/references/structure.md b/.agents/skills/wp-plugin-development/references/structure.md new file mode 100644 index 0000000..b05503b --- /dev/null +++ b/.agents/skills/wp-plugin-development/references/structure.md @@ -0,0 +1,16 @@ +# Plugin structure and loading + +Use this file when introducing or refactoring a plugin architecture. + +## Core concepts + +- Main plugin file contains the plugin header and bootstraps the plugin. +- Prefer predictable init: + - minimal boot file + - a loader/class that registers hooks + - admin-only code behind admin hooks + +Upstream reference: + +- https://developer.wordpress.org/plugins/plugin-basics/ + diff --git a/.agents/skills/wp-plugin-development/scripts/detect_plugins.mjs b/.agents/skills/wp-plugin-development/scripts/detect_plugins.mjs new file mode 100644 index 0000000..cf32477 --- /dev/null +++ b/.agents/skills/wp-plugin-development/scripts/detect_plugins.mjs @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_IGNORES = new Set([ + ".git", + "node_modules", + "vendor", + "dist", + "build", + "coverage", + ".next", + ".turbo", +]); + +function statSafe(p) { + try { + return fs.statSync(p); + } catch { + return null; + } +} + +function readFileSafe(p, maxBytes = 128 * 1024) { + try { + const buf = fs.readFileSync(p); + if (buf.byteLength > maxBytes) return buf.subarray(0, maxBytes).toString("utf8"); + return buf.toString("utf8"); + } catch { + return null; + } +} + +function findFilesRecursive(repoRoot, predicate, { maxFiles = 6000, maxDepth = 10 } = {}) { + const results = []; + const queue = [{ dir: repoRoot, depth: 0 }]; + let visited = 0; + + while (queue.length > 0) { + const { dir, depth } = queue.shift(); + if (depth > maxDepth) continue; + + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const ent of entries) { + const fullPath = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (DEFAULT_IGNORES.has(ent.name)) continue; + queue.push({ dir: fullPath, depth: depth + 1 }); + continue; + } + if (!ent.isFile()) continue; + + visited += 1; + if (visited > maxFiles) return { results, truncated: true }; + if (predicate(fullPath)) results.push(fullPath); + } + } + + return { results, truncated: false }; +} + +function parsePluginHeader(contents) { + // WordPress reads plugin headers from the top of the file. We only need key fields. + const header = {}; + const pairs = [ + ["Plugin Name", "name"], + ["Plugin URI", "uri"], + ["Description", "description"], + ["Version", "version"], + ["Author", "author"], + ["Author URI", "authorUri"], + ["Text Domain", "textDomain"], + ["Domain Path", "domainPath"], + ]; + for (const [label, key] of pairs) { + const m = contents.match(new RegExp(`^\\s*${label}:\\s*(.+)\\s*$`, "im")); + if (m) header[key] = m[1].trim(); + } + if (!header.name) return null; + return header; +} + +function main() { + const repoRoot = process.cwd(); + + const { results: phpFiles, truncated } = findFilesRecursive(repoRoot, (p) => p.toLowerCase().endsWith(".php"), { + maxFiles: 5000, + maxDepth: 10, + }); + + const plugins = []; + + for (const phpPath of phpFiles) { + const txt = readFileSafe(phpPath); + if (!txt) continue; + if (!/Plugin Name:/i.test(txt)) continue; + const header = parsePluginHeader(txt); + if (!header) continue; + plugins.push({ + pluginFile: path.relative(repoRoot, phpPath), + ...header, + }); + } + + const report = { + tool: { name: "detect_plugins", version: "0.1.0" }, + repoRoot, + truncated, + count: plugins.length, + plugins, + }; + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +main(); + diff --git a/.agents/skills/wp-plugin-directory-guidelines/SKILL.md b/.agents/skills/wp-plugin-directory-guidelines/SKILL.md new file mode 100644 index 0000000..4fd6776 --- /dev/null +++ b/.agents/skills/wp-plugin-directory-guidelines/SKILL.md @@ -0,0 +1,132 @@ +--- +name: wp-plugin-directory-guidelines +description: "Use when reviewing WordPress plugins for GPL compliance, checking license headers or compatibility, evaluating upsell/freemium/trialware patterns, validating plugin naming or trademark rules, checking plugin slugs, understanding why a plugin was rejected from WordPress.org, or answering any question about the 18 WordPress.org Plugin Directory guidelines — even if the user doesn't mention 'guidelines' explicitly." +compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+)." +--- + +## Overview + +Authoritative reference for the 18 WordPress.org Plugin Directory guidelines. Covers GPL licensing, plugin naming/trademark rules, trialware restrictions, and all other submission requirements. + +## When to use + +Use this skill when you need to: +- Review a WordPress plugin for compliance with the WordPress.org Plugin Directory guidelines +- Check GPL license compatibility for a plugin or its bundled libraries +- Verify license headers in plugin files +- Identify common guideline violations before submission +- Answer questions about what is or is not allowed on WordPress.org +- Evaluate premium/upsell flows, license checks, or freemium positioning +- Review "teaser" or "preview" UI for trialware violations + +## Inputs required + +- Plugin source code (or specific files to review) +- Optional: plugin readme and plugin header metadata for naming and license checks + +## Procedure + +1. Check the plugin's license header against the **Valid License Headers** section below. +2. Walk through the **18 Guidelines** checklist, paying special attention to Guidelines 1, 4, 5, 7, 8, and 17. +3. Confirm trialware/freemium compliance using the checklist in [guideline-review-checklist.md](references/guideline-review-checklist.md) (Guideline 5 section). +4. For bundled third-party code, verify license compatibility against **GPL-Compatible Licenses (Quick)** below. +5. Flag matches from **Common GPL Violations (Quick)** below. +6. For edge cases, consult the detailed references and the [GNU GPL FAQ](https://www.gnu.org/licenses/gpl-faq.html). + +## 18-Guideline Review Checklist + +Use the detailed, per-guideline checklist in [guideline-review-checklist.md](references/guideline-review-checklist.md). Load this reference file only when a full guideline audit is requested. + +## GPL Compliance (Guideline 1 in Detail) + +Use [gpl-compliance.md](references/gpl-compliance.md) for full license tables, compatibility nuances, and examples. Keep this inline section as a quick decision aid. + +### Verification (Licensing) + +- Every licensing-related issue must cite **Guideline 1** and include the file path and exact license string. +- Confirm compatibility claims against **GPL-Compatible Licenses (Quick)** and escalate ambiguous licenses. + +### Failure modes (Licensing) + +- If a license is not clearly GPL-compatible, do not guess. Check the [GNU license list](https://www.gnu.org/licenses/license-list.html). +- For dual-license packages, verify both licenses and redistribution terms. + +### Quick Reference: WordPress GPL Requirements + +- WordPress is **GPLv2 or later**. +- Plugins distributed on WordPress.org must be 100% GPL-compatible (code and assets). +- Include a valid `License:` header and `License URI:` in the main plugin file. +- Do not add restrictions that conflict with GPL freedoms. + +### Valid License Headers + +## GPL Versions Summary + +| Version | Year | Key Addition | +|---------|------|--------------| +| GPLv1 | 1989 | Base copyleft: share-alike for modifications | +| GPLv2 | 1991 | "Liberty or death" clause (Section 7), clearer distribution terms | +| GPLv3 | 2007 | Anti-tivoization, explicit patent grants, compatibility provisions | + +WordPress uses **GPLv2 or later**, meaning plugins can use GPLv2, GPLv3, or "GPLv2 or later". + +For full license texts, see: +- [GNU General Public License v1](https://www.gnu.org/licenses/gpl-1.0.html) +- [GNU General Public License v2](https://www.gnu.org/licenses/gpl-2.0.html) +- [GNU General Public License v3](https://www.gnu.org/licenses/gpl-3.0.html) + +## License Compliance Checklist + +When reviewing a plugin, verify: + +- [ ] Main plugin file has a valid `License:` header (e.g., `GPL-2.0-or-later`, `GPL-2.0+`, `GPLv2 or later`) +- [ ] Main plugin file has a `License URI:` header pointing to the GPL text +- [ ] If bundled libraries exist, each has a GPL-compatible license +- [ ] No "split licensing" (e.g., code GPL but premium features proprietary) +- [ ] No additional restrictions beyond what GPL allows +- [ ] No clauses restricting commercial use, modification, or redistribution +- [ ] No obfuscated code (violates the spirit of source code availability) + +## Valid License Headers for WordPress Plugins + +``` +License: GPL-2.0-or-later +License URI: https://www.gnu.org/licenses/gpl-2.0.html +``` + +```text +License: GPL-3.0-or-later +License URI: https://www.gnu.org/licenses/gpl-3.0.html +``` + +```text +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html +``` + +### GPL-Compatible Licenses (Quick) + +- Safe defaults: GPL-2.0-or-later, GPL-3.0-or-later. +- Commonly accepted permissive families: MIT/Expat, BSD, ISC, zlib, Boost. +- Conditional compatibility requires care: Apache-2.0 and MPL-2.0 (verify usage context). +- For full accepted and rejected identifiers, use [gpl-compliance.md](references/gpl-compliance.md). + +### Common GPL Violations (Quick) + +- Split licensing that restricts distributed code. +- Obfuscated or non-corresponding source distribution. +- Restrictive clauses (non-commercial, no-resale, forced backlink). +- Bundling GPL-incompatible libraries or assets. + +## Plugin Naming Rules (Guideline 17) + +Use [naming-rules.md](references/naming-rules.md) for full trademark lists, slug blocks, and naming examples. Keep this inline checklist for quick screening. + +### Naming Checklist (Quick) + +- Name is not a placeholder and has at least 5 alphanumeric characters. +- Header name and readme name match. +- Name is specific and function-related; avoid keyword stuffing. +- Trademark/project names appear only after connectors like `for`, `with`, `using`, `and`. +- No banned/discouraged terms or trademark portmanteaus. +- Slug is lowercase, hyphenated, <= 50 chars, and avoids blocked terms. diff --git a/.agents/skills/wp-plugin-directory-guidelines/references/gpl-compliance.md b/.agents/skills/wp-plugin-directory-guidelines/references/gpl-compliance.md new file mode 100644 index 0000000..7656fba --- /dev/null +++ b/.agents/skills/wp-plugin-directory-guidelines/references/gpl-compliance.md @@ -0,0 +1,217 @@ +## GPL Compliance (Guideline 1 in Detail) + +### Verification (Licensing) + +- Every licensing-related issue must cite **Guideline 1** and include the specific file/license value found. +- License compatibility claims must match the GPL-Compatible Licenses table or the [GNU GPL-Compatible License List](https://www.gnu.org/licenses/license-list.html#GPLCompatibleLicenses). +- Use local `references/` files when present; otherwise use authoritative external URLs. + +### Failure modes (Licensing) + +- If a license is not listed in the compatibility tables, do not guess; check the [GNU license list](https://www.gnu.org/licenses/license-list.html) or escalate. +- If a plugin uses a dual-license model, verify both licenses independently. + + +### Quick Reference: WordPress GPL Requirements + +WordPress is licensed under **GPLv2 or later**. All plugins distributed via WordPress.org must be: + +1. **100% GPL-compatible** (code, images, CSS, and all assets) +2. Include a **license declaration** in the main plugin file header +3. Include the **full license text** or a URI reference to it +4. **Not restrict freedoms** granted by the GPL + +## GPL Versions Summary + +| Version | Year | Key Addition | +|---------|------|--------------| +| GPLv1 | 1989 | Base copyleft: share-alike for modifications | +| GPLv2 | 1991 | Patent clause (Section 7), clearer distribution terms | +| GPLv3 | 2007 | Anti-tivoization, explicit patent grants, compatibility provisions | + +WordPress uses **GPLv2 or later**, meaning plugins can use GPLv2, GPLv3, or "GPLv2 or later". + +For full license texts, see: +- [GNU General Public License v1](https://www.gnu.org/licenses/gpl-1.0.html) +- [GNU General Public License v2](https://www.gnu.org/licenses/gpl-2.0.html) +- [GNU General Public License v3](https://www.gnu.org/licenses/gpl-3.0.html) + +## License Compliance Checklist + +When reviewing a plugin, verify: + +- [ ] Main plugin file has a valid `License:` header (e.g., `GPL-2.0-or-later`, `GPL-2.0+`, `GPLv2 or later`) +- [ ] Main plugin file has a `License URI:` header pointing to the GPL text +- [ ] If bundled libraries exist, each has a GPL-compatible license +- [ ] No "split licensing" (e.g., code GPL but premium features proprietary) +- [ ] No additional restrictions beyond what GPL allows +- [ ] No clauses restricting commercial use, modification, or redistribution +- [ ] No obfuscated code (violates the spirit of source code availability) + +## Valid License Headers for WordPress Plugins + +``` +License: GPL-2.0-or-later +License URI: https://www.gnu.org/licenses/gpl-2.0.html +``` + +``` +License: GPL-3.0-or-later +License URI: https://www.gnu.org/licenses/gpl-3.0.html +``` + +``` +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html +``` + +## Accepted Licenses by the WordPress.org Plugin Directory + +Source: [Plugin Check - License_Utils trait](https://github.com/WordPress/plugin-check/blob/trunk/includes/Traits/License_Utils.php) + +The Plugin Directory accepts licenses matching these identifiers (after normalization). The validation uses `is_license_gpl_compatible()` with the pattern: + +``` +GPL|GNU|LGPL|MIT|FreeBSD|New BSD|BSD-3-Clause|BSD 3 Clause|OpenLDAP|Expat|Apache2|MPL20|ISC|CC0|Unlicense|WTFPL|Artistic|Boost|NCSA|ZLib|X11 +``` + +### GPL Family (recommended) + +| Accepted Values | SPDX Identifier | License URI | +|-----------------|-----------------|-------------| +| `GPL-2.0-or-later`, `GPLv2 or later`, `GPL-2.0+` | GPL-2.0-or-later | https://www.gnu.org/licenses/gpl-2.0.html | +| `GPL-2.0-only`, `GPLv2` | GPL-2.0-only | https://www.gnu.org/licenses/gpl-2.0.html | +| `GPL-3.0-or-later`, `GPLv3 or later`, `GPL-3.0+` | GPL-3.0-or-later | https://www.gnu.org/licenses/gpl-3.0.html | +| `GPL-3.0-only`, `GPLv3` | GPL-3.0-only | https://www.gnu.org/licenses/gpl-3.0.html | +| `GNU General Public License` (any version text) | — | — | +| `LGPL-2.1`, `LGPLv2.1` | LGPL-2.1-or-later | https://www.gnu.org/licenses/lgpl-2.1.html | +| `LGPL-3.0`, `LGPLv3` | LGPL-3.0-or-later | https://www.gnu.org/licenses/lgpl-3.0.html | + +### Other GPL-Compatible Licenses Accepted + +| Identifier | License Name | Notes | +|------------|-------------|-------| +| `MIT` | MIT License | Permissive, compatible with GPLv2 and GPLv3 | +| `Expat` | Expat License | Functionally equivalent to MIT | +| `X11` | X11 License | Permissive; similar to Expat but with extra X Consortium clause | +| `FreeBSD` | BSD 2-Clause (FreeBSD) | Permissive, compatible with GPLv2 and GPLv3 | +| `New BSD`, `BSD-3-Clause`, `BSD 3 Clause` | BSD 3-Clause | Permissive, compatible with GPLv2 and GPLv3 | +| `Apache2`, `Apache-2.0` | Apache License 2.0 | Compatible with GPLv3 only (NOT GPLv2) | +| `MPL20`, `MPL-2.0` | Mozilla Public License 2.0 | Compatible via Section 3.3 | +| `ISC` | ISC License | Permissive, compatible with GPLv2 and GPLv3 | +| `OpenLDAP` | OpenLDAP Public License v2.7 | Permissive; older v2.3 is NOT compatible | +| `CC0` | Creative Commons Zero | Public domain dedication | +| `Unlicense` | The Unlicense | Public domain dedication | +| `WTFPL` | Do What The F*** You Want To Public License | Permissive, accepted in full text form too | +| `Artistic` | Artistic License 2.0 | Compatible via relicensing option in §4(c)(ii); Artistic 1.0 is NOT compatible | +| `Boost` | Boost Software License 1.0 | Lax permissive, compatible with GPLv2 and GPLv3 | +| `NCSA` | NCSA/University of Illinois Open Source License | Based on Expat + modified BSD; compatible with GPLv2 and GPLv3 | +| `ZLib` | zlib License | Permissive, compatible with GPLv2 and GPLv3 | + +### Licenses NOT Accepted + +Any license not matching the identifiers above will be rejected. Common rejections include: + +- **Proprietary / All Rights Reserved** +- **Creative Commons BY-NC** (NonCommercial restriction) +- **Creative Commons BY-ND** (NoDerivatives restriction) +- **Creative Commons BY-SA** (v3.0 and earlier; v4.0 is one-way compatible with GPLv3 but not in the Plugin Check regex) +- **JSON License** ("shall be used for Good, not Evil") +- **SSPL** (Server Side Public License) +- **BSL** (Business Source License) +- **Commons Clause** +- **Elastic License** +- **Original BSD (4-clause)** — advertising clause incompatible with GPL +- **MPL-1.0** — only MPL 2.0 is GPL-compatible +- **EPL** (Eclipse Public License) — weak copyleft, incompatible with GPL +- **EUPL** (European Union Public License) — copyleft incompatible with GPL without multi-step relicensing +- **Artistic License 1.0** — vague wording makes it incompatible; use 2.0 instead +- **OpenLDAP v2.3** (old) — incompatible; v2.7 is accepted + +## Common GPL Violations in Plugin Review + +### 1. Split Licensing +Plugin claims GPL but restricts premium features: +- "Free version is GPL, premium is proprietary" - **VIOLATION** +- All code distributed must be GPL-compatible + +### 2. Obfuscated Code +- Minified JavaScript is acceptable IF source is provided +- PHP obfuscation (ionCube, Zend Guard, etc.) - **VIOLATION** (prevents exercise of GPL freedoms) +- Encoded/encrypted PHP - **VIOLATION** + +### 3. Missing License Information +- No license header in main file +- No license file in the package +- Bundled libraries without license documentation + +### 4. Restrictive Clauses +- "You may not sell this plugin" - **VIOLATION** (GPL allows commercial redistribution) +- "You may not remove author credits" - Acceptable under GPLv3 Section 7(b), but not as blanket restriction +- "For personal use only" - **VIOLATION** +- "You must link back to our site" - **VIOLATION** (additional restriction) + +### 5. Incompatible Library Inclusion +- Including code under GPL-incompatible licenses +- Using assets (images, fonts, CSS) under restrictive licenses + +## Key GPL Concepts for Reviewers + +### Distribution vs. Private Use +- GPL obligations activate upon **distribution** (conveying to others) +- Private modifications do NOT trigger GPL requirements +- Publishing on WordPress.org IS distribution + +### Derivative Works +- A WordPress plugin that uses WordPress APIs is generally considered a derivative work +- Plugins that merely aggregate with WordPress may have different considerations +- When in doubt, the safe approach is GPL-compatible licensing + +### Source Code Requirement +- GPL requires access to "complete corresponding source code" +- For WordPress plugins: all PHP, JS source files, build scripts +- Minified files must have corresponding source available + +### The "Or Later" Clause +- "GPLv2 or later" allows users to choose GPLv2 OR any later version +- "GPLv2 only" means strictly GPLv2 (less flexible but valid) +- WordPress itself uses "GPLv2 or later" + +## Violation Reporting Workflow + +When a GPL violation is identified: + +1. **Document the violation** precisely: + - Product name and version + - Distributor information + - Specific license terms violated + - Evidence (screenshots, code snippets) + +2. **Contact the copyright holder** first +3. **Report to FSF** if the code is FSF-copyrighted: license-violation@gnu.org +4. **For WordPress.org plugins**: flag through the plugin review process + +## Frequently Asked Questions + +For comprehensive GPL FAQ answers, see the [GNU GPL FAQ](https://www.gnu.org/licenses/gpl-faq.html). + +Common questions during plugin review: + +**Can a plugin charge money and still be GPL?** +Yes. GPL allows charging for distribution. The requirement is that recipients get GPL freedoms (use, modify, redistribute). + +**Does a plugin need to include the full GPL text?** +GPLv2 Section 1 and GPLv3 Section 4 require giving recipients a copy of the license. A URI reference in the header plus including a LICENSE file is standard practice. + +**Can a plugin restrict who uses it?** +No. GPL explicitly prohibits additional restrictions on recipients. "For personal use only" or "non-commercial" clauses are incompatible. + +**Is minified JS without source a violation?** +If the plugin only distributes minified JS without any way to obtain the source, this conflicts with GPL's source code requirements. The source should be available (in the package or via a repository). + +**Can a plugin use CC-BY-SA images?** +CC-BY-SA 4.0 is one-way compatible with GPLv3 (CC-BY-SA material can be included in GPLv3 works). CC-BY-SA 3.0 is NOT compatible. + +**What about fonts bundled in plugins?** +Fonts must be under GPL-compatible licenses. Common acceptable font licenses: OFL (SIL Open Font License), Apache 2.0 (with GPLv3), MIT, GPL with font exception. + diff --git a/.agents/skills/wp-plugin-directory-guidelines/references/guideline-review-checklist.md b/.agents/skills/wp-plugin-directory-guidelines/references/guideline-review-checklist.md new file mode 100644 index 0000000..f511d0e --- /dev/null +++ b/.agents/skills/wp-plugin-directory-guidelines/references/guideline-review-checklist.md @@ -0,0 +1,592 @@ +# WordPress.org Plugin Directory Guidelines — Review Checklist + +Source: [Detailed Plugin Guidelines](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/?output_format=md) + +Use this section as a structured checklist when reviewing a plugin. Each guideline includes the violation signal to look for, the verdict to issue, and the fix to recommend. Cite the guideline number in every finding. + +## Contents + +- [WordPress.org Plugin Directory Guidelines — Review Checklist](#wordpressorg-plugin-directory-guidelines--review-checklist) + - [Contents](#contents) + - [Guideline 1: GPL-Compatible License](#guideline-1-gpl-compatible-license) + - [Guideline 2: Developer Responsibility](#guideline-2-developer-responsibility) + - [Guideline 3: Stable Version in SVN](#guideline-3-stable-version-in-svn) + - [Guideline 4: Human-Readable Code](#guideline-4-human-readable-code) + - [Guideline 5: No Trialware](#guideline-5-no-trialware) + - [Guideline 6: SaaS Integrations Are Allowed — With Conditions](#guideline-6-saas-integrations-are-allowed--with-conditions) + - [Guideline 7: No External Data Collection Without Consent](#guideline-7-no-external-data-collection-without-consent) + - [Guideline 8: No Remotely Loaded Executable Code](#guideline-8-no-remotely-loaded-executable-code) + - [Guideline 9: No Illegal, Dishonest, or Offensive Behavior](#guideline-9-no-illegal-dishonest-or-offensive-behavior) + - [Guideline 10: No Forced External Links](#guideline-10-no-forced-external-links) + - [Guideline 11: No Admin Dashboard Hijacking](#guideline-11-no-admin-dashboard-hijacking) + - [Guideline 12: No Readme Spam](#guideline-12-no-readme-spam) + - [Guideline 13: Use WordPress-Bundled Libraries](#guideline-13-use-wordpress-bundled-libraries) + - [Guideline 14: SVN Is a Release Repository](#guideline-14-svn-is-a-release-repository) + - [Guideline 15: Increment Version Numbers](#guideline-15-increment-version-numbers) + - [Guideline 16: Plugin Must Be Complete at Submission](#guideline-16-plugin-must-be-complete-at-submission) + - [Guideline 17: Respect Trademarks and Copyrights](#guideline-17-respect-trademarks-and-copyrights) + - [Guideline 18: WordPress.org Reserves Directory Rights](#guideline-18-wordpressorg-reserves-directory-rights) + +--- + +### Guideline 1: GPL-Compatible License + +**Check:** Does the main plugin file have a `License:` header with a GPL-compatible value? Are all bundled third-party libraries under compatible licenses? + +**Violation signals:** +- Missing `License:` or `License URI:` header in the main plugin file +- License is `Proprietary`, `All Rights Reserved`, `CC-BY-NC`, `CC-BY-ND`, `SSPL`, `BSL`, `Commons Clause`, `EPL`, `EUPL`, or `MPL-1.0` +- Bundled library under a license not in the GPL-Compatible Licenses table (see below) +- PHP files encoded with ionCube, Zend Guard, or similar — source cannot be exercised → violation + +**Verdict:** Flag as **FAIL** with the specific file and license value found. + +**Fix:** Use `GPL-2.0-or-later` (recommended). Add full license text or a `License URI:` to `https://www.gnu.org/licenses/gpl-2.0.html`. Replace incompatible libraries. + +--- + +### Guideline 2: Developer Responsibility + +**Check:** Has the developer deliberately re-introduced previously removed code, circumvented a prior guideline decision, or included files they cannot legally distribute? + +**Violation signals:** +- Commit history shows restoring a file after it was removed by the review team +- Bundled assets with no documented license (treat as unlicensed until proven otherwise) +- Third-party API terms prohibit redistribution of the bundled SDK + +**Verdict:** Flag as **FAIL**. Document the specific file or commit. + +**Fix:** Remove the offending file or obtain and document proper licensing. + +--- + +### Guideline 3: Stable Version in SVN + +**Check:** Is the WordPress.org SVN version the canonical release? Is the plugin also distributed via an external channel with a newer version? + +**Violation signals:** +- `readme.txt` advertises a version not present in SVN trunk/tags +- External download page (developer's own site) offers a newer build than WP.org +- Plugin auto-updates itself from a non-WP.org server (also a Guideline 8 issue) + +**Verdict:** Flag as **FAIL** if an actively maintained external version is ahead of the directory. + +**Fix:** Keep SVN up to date. External channels may mirror but must not supersede the directory version. + +--- + +### Guideline 4: Human-Readable Code + +**Check:** Is all PHP, JS, and CSS in a form that a developer can read and understand? Are build sources available? + +**Violation signals:** +- PHP obfuscated with packer, eval+base64 chains, or variable names like `$a1b2c3` throughout +- Minified JS present **without** any source map or reference to the source repo/file in the readme +- Build artifacts (`.min.js`) committed with no corresponding unminified source in the package or a public repo linked from `readme.txt` + +**Verdict:** Flag as **FAIL** for obfuscated PHP (always). Flag minified-only JS as **FAIL** if no source access is documented. + +**Fix:** Remove obfuscation. Add a `Development` or `Build` section to `readme.txt` linking to the source repo (GitHub, GitLab, etc.). + +--- + +### Guideline 5: No Trialware + +**Core rule:** Every feature shipped in the directory must function end-to-end without a license key, payment, or account. + +**Check for each feature gate in the code:** + +1. Does a `has_paid_access()` / `is_licensed()` / `check_license()` check gate **local** processing (not an external service call)? +2. Is there a time-based expiry (`time() > $installed_at + 30 * DAY_IN_SECONDS`) for local behavior? +3. Is there a usage quota (`if ( $count >= 100 )`) that is artificially low and only exists to pressure upgrades? +4. Does the free user see a blocked/locked UI that prevents completing a core workflow? + +**Violation signals (flag as FAIL):** +- `return` / `wp_die()` / blocking screen shown when `has_paid_access()` is false for a local feature +- Ternary limits: `$limit = $licensed ? 10000 : 100` with no filter to extend the free cap +- Features expire after X days even when no external service is involved +- Admin screen is entirely replaced with an upgrade prompt + +**Allowed patterns (do not flag):** +- Upsell notice shown alongside a working free feature (non-blocking) +- Premium feature delegated to a **separate** add-on plugin not hosted on WP.org +- External SaaS feature gated because the **service** itself requires payment (e.g., AI API quota) +- Dismissible comparison table or upgrade button in plugin settings + +**Code patterns:** + +```php +// VIOLATION — local feature blocked by paid check +if ( ! $this->has_paid_access() ) { + echo 'Upgrade required'; + return; // ← blocks execution +} + +// VIOLATION — artificial cap with no extension point +$limit = $this->has_paid_access() ? 10000 : 100; +``` + +```php +// COMPLIANT — free path works; premium adds to it +$this->render_basic_export(); +if ( $this->has_premium_addon() ) { + do_action( 'myplugin_premium_export_options' ); +} + +// COMPLIANT — cap is consistent; extensible via filter +$limit = apply_filters( 'myplugin_event_limit', 10000 ); +``` + +**Pre-submission checklist:** +- [ ] All free features work without a license key +- [ ] No time-based expirations or usage quotas for local behavior +- [ ] No blocking/locked UI preventing free-tier workflows +- [ ] Upsell prompts are informational, non-blocking, and dismissible +- [ ] Premium-only code lives in a separate add-on or an external service + +--- + +### Guideline 6: SaaS Integrations Are Allowed — With Conditions + +**Check:** Does the external service provide real functionality? Is it documented in the readme? + +**Violation signals:** +- The external service's sole purpose is validating a license key; all actual processing is local +- Code was moved server-side specifically to disguise what is really a local feature gate +- Plugin is a storefront or checkout page for an external product with no real plugin functionality + +**Verdict:** Flag as **FAIL** for license-validation-only services. Do not flag genuine SaaS integrations. + +**Fix:** Document what the external service does in `readme.txt`. Move license validation out of the plugin's critical path if the functionality is local. + +--- + +### Guideline 7: No External Data Collection Without Consent + +**Check:** Does the plugin send any data to an external server without the user explicitly opting in? + +**Violation signals:** +- HTTP request to a remote URL on plugin activation, admin page load, or cron job with no user opt-in +- User email, site URL, or usage data sent without a visible opt-in checkbox or registration step +- Third-party analytics or ad-tracking scripts loaded in admin or frontend without consent +- Assets (images, fonts, scripts) loaded from an external CDN that are not the plugin's primary service + +**Exception:** Plugins that are interfaces to a named third-party service (e.g., Akismet, Mailchimp, a CDN) — consent is implied when the user configures the service connection. + +**Code patterns (violation vs compliant):** + +```php +// VIOLATION — sends data on activation without consent +register_activation_hook( __FILE__, function() { + wp_remote_post( + 'https://api.example.com/collect', + array( + 'body' => array( + 'site' => home_url(), + 'admin_email' => get_option( 'admin_email' ), + ), + ) + ); +} ); + +// COMPLIANT — explicit opt-in gate +if ( isset( $_POST['myplugin_opt_in'] ) && '1' === $_POST['myplugin_opt_in'] ) { + update_option( 'myplugin_tracking_opt_in', 1 ); +} + +if ( get_option( 'myplugin_tracking_opt_in' ) ) { + wp_remote_post( 'https://api.example.com/collect', $payload ); +} +``` + +**Review questions:** +1. Is any outbound request made on activation, first-run, or cron before consent is stored? +2. Is the opt-in UI explicit, unambiguous, and default-off? +3. Is consent persisted and checked before every telemetry request path? +4. Are collected fields documented in `readme.txt` privacy disclosures? + +**Verdict:** Flag as **FAIL** for any unconsented outbound call. Include the specific URL or domain found. + +**Fix:** Wrap all outbound calls in an opt-in gate. Add a `Privacy Policy` section to `readme.txt` describing what data is collected and why. + +**Pre-submission checklist:** +- [ ] No telemetry/analytics calls occur before explicit opt-in +- [ ] Opt-in control is visible and off by default +- [ ] Consent is stored and checked in every outbound path +- [ ] Privacy policy in readme explains data, destination, and purpose + +--- + +### Guideline 8: No Remotely Loaded Executable Code + +**Check:** Is all JS/CSS that runs on the user's site included in the plugin package? + +**Violation signals:** +- `wp_enqueue_script()` loading JS from a third-party CDN (not a self-hosted asset) +- Plugin fetches and executes code from an external URL at runtime (`file_get_contents` + `eval`, dynamic `