diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 2486894..d983a1b 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `lens` manifest key — an array of the lens skills that should check a scaffold's output once its tests are green, most relevant first, from a closed list (`accessibility`, `i18n`, `performance`, `security`, `seo`, `vip-readiness`). `validate` rejects a bare string, an empty array, duplicates, and an MCP ability name such as `runtime-health`, which is something a lens reads rather than a lens. `execute()` reports it as `scaffold.lens` and `list --json` as `lens`, both `null` when undeclared, and `null` in `list` for remote scaffolds whose manifests are not fetched. Additive: `result.scaffold` gains one field. +- New `integration` category for opinionated platform integrations, and the first scaffolds to use the reserved `"wizard_step": "integrations"`. Each builds on a `wp` kind, reuses its wiring anchor, and degrades to standard behaviour off the platform. +- `integration/vip-search` scaffold — a `Registrable` that sends heavy meta and taxonomy main queries on one post type to VIP Search through `ep_integrate`, which works on any ElasticPress backend too. Adds `META_KEYS` to VIP Search's post meta allow list (it indexes none by default), preserving whichever list or map format the allow list arrived in, since VIP discards entries that do not match it. Fires a prefixed action whenever a query that asked for the index falls back to MySQL — the silent outcome when query integration is not enabled for the environment, under VIP search rate limiting, or on an index error. Changes only the main query from `pre_get_posts`, resolving an archive's empty `post_type` the way `WP_Query` does, so category, tag and custom taxonomy archives on the post type are offloaded; secondary queries opt in where they are built. No-ops without `EP_VERSION`. +- `integration/vip-remote-request` scaffold — a service that fetches remote data over GET without an uncached request on the page path. On VIP it delegates to `wpcom_vip_file_get_contents()` (cached, stale-on-failure, backs off a failing host); `vip_safe_wp_remote_get()` is deliberately not used because it caches nothing. Elsewhere it caches successful responses only in the object cache via `rtCamp\WPPrimitives\Utils\Cache` and fetches with `wp_safe_remote_get()`. Internal hosts are refused on both paths, and only when a request is actually sent, so a cached or stale copy never waits on a DNS lookup. `timeout` and `cache_ttl` are `enum`-constrained to what `RemoteRequestTimeout` (at most 3s) and `LowExpiryCacheTime` (at least 300s) accept. +- `integration/vip-webhook` scaffold — a REST controller that verifies an HMAC-SHA256 signature over the raw body in constant time, refuses every delivery until a shared secret is set (a VIP environment variable, or a constant of the same prefixed name elsewhere — never written by the scaffold), caps the body at 64 KB, accepts a JSON object or an array (as batch senders post), acknowledges a repeated delivery to the same webhook with 200 without queueing it twice for a day (atomically in a persistent object cache, in a transient otherwise, so retries are caught without one), and queues the payload on Action Scheduler or WP-Cron before answering 202 instead of doing the work in the request. The REST module it wires into is the `module_path` input, so a project whose module is `REST.php` is not sent to a `Rest.php` that does not exist. + ## [2.0.0] - 2026-10-01 ### Added diff --git a/node-packages/wp-tooling/docs/ai-orchestration.md b/node-packages/wp-tooling/docs/ai-orchestration.md index 2fa1dfa..0fff6ea 100644 --- a/node-packages/wp-tooling/docs/ai-orchestration.md +++ b/node-packages/wp-tooling/docs/ai-orchestration.md @@ -35,7 +35,7 @@ Result shape, in brief: ```json { - "scaffold": { "id", "slug", "kind", "dryRun" }, + "scaffold": { "id", "slug", "kind", "lens", "dryRun" }, "engine": { "wrote": [...], "skipped": [...], "inputs": {...} }, "developer": { "install": { "composer": {...}, "npm": {...} }, "secrets": [...] }, "ai": { "wiring": [...], "tests": [...] }, @@ -58,6 +58,7 @@ The engine commits to the following on a successful run. Skills can rely on thes - The engine never reads or writes any path outside `--cwd`. This is enforced: a rendered `dest`, test path, or feature-owned path that resolves outside the target directory (e.g. via `..` in a path input or a third-party remote manifest) fails with `EWRITEFAIL` (`errno: "EOUTSIDE"`) before anything touches the filesystem. - The engine never invokes `gh`, `git`, `composer`, `npm`, or any other external CLI on behalf of the caller. - `scaffold.kind` is `"package"` for `source: "package"` scaffolds (no files written, only deps and wiring; the bundled examples are `utility/*`) and `"template"` otherwise. Remote (sources) scaffolds also report `kind: "template"` — they render Mustache the same way as local ones; where the scaffold lives is an implementation detail the orchestrator does not need to branch on. Callers branch on `kind` rather than checking `engine.wrote.length === 0`. +- `scaffold.lens` is the manifest's `lens` array, in the manifest's order (most relevant first), or `null` when the scaffold declares none. Each entry names a lens **skill** (`accessibility`, `i18n`, `performance`, `security`, `seo`, `vip-readiness`), never an MCP ability. It is a recommendation, not a dependency: after the scaffolded code's tests go green, run each named lens that is installed and report any that is not, rather than failing. `null` leaves the choice to the skill. `wp-tooling list --json` carries the same field, `null` for remote scaffolds, whose manifests `list` does not fetch. - `wp-tooling list --json` entries carry an `origin` of `"default"`, `"project"`, or `"remote"`. Remote scaffolds come from a repo's cached index (`sources.json` → each repo's `index.json`), so their `counts` is `null` (unknown until `add`); local scaffolds carry real `counts`. `list` is online-preferred with a cache fallback: it reads the index (cached, ETag-validated), and a `warnings` array reports any source that was unreachable and uncached. The top-level `{ scaffolds, warnings }` shape carries those notes. - The engine core has zero dependency on the TTY UI kit. AI orchestration mode never loads any terminal-UI primitive. Skills can rely on the engine being usable from any context, including non-TTY containers, CI runners, and headless test harnesses. - File-based `discover_from` (`composer.json:` including `autoload.psr-4` and `autoload-dev.psr-4`, `package.json:`, `plugin-header:` from the plugin or theme entry header, `config:` from `.wp-tooling.json`) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` sources are **not** engine-resolved and remain the skill's responsibility (§6). @@ -413,6 +414,7 @@ The JSON shape is part of `@rtcamp/wp-tooling`'s public API and follows semver: - **New error codes** may be added in a minor bump. Skills should handle unknown codes by surfacing the message and exiting non-zero rather than crashing. - **New `framework` values** (currently `phpunit`, `jest`, `playwright`, `pa11y`, `actionlint`, `yaml-parse`) may be added in a minor bump. - **New `scope` values** for secrets (currently `github-actions`, `env`, `dotenv`) may be added in a minor bump. +- **New `lens` values** (currently `accessibility`, `i18n`, `performance`, `security`, `seo`, `vip-readiness`) may be added in a minor bump. Skills should skip a lens they do not recognise. Pinning `@rtcamp/wp-tooling` to a specific major in your skill's package manifest is the supported way to maintain forward compatibility. diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index 04b48fa..0b11948 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -251,6 +251,25 @@ project may prefer. --- +## Lens (`lens[]`) + +`lens` names the lens skills that should check a scaffold's output once its tests are green, most relevant first: + +```json +"lens": ["vip-readiness", "performance", "security"] +``` + +- **Always an array**, even for one lens. A bare string is rejected, as are an empty array and duplicates. +- **Lens skills only**, from a closed list: `accessibility`, `i18n`, `performance`, `security`, `seo`, `vip-readiness`. A lens is a skill (`skills//` in wp-dev-tools) that decides for itself which MCP abilities to read, so an ability name such as `runtime-health` is rejected — name the lens that reads it (`performance`) instead. A typo fails `validate` rather than silently skipping the check. +- **A recommendation, not a dependency.** The list includes lenses a given project may not have installed; the orchestrating skill runs the ones it finds. That is also why the enum can name a lens before it ships, without a wp-tooling release for each new one. +- **Optional.** Omit it and `execute()` and `list --json` report `lens: null`, leaving the choice to the orchestrating skill. + +Order by what the generated code most needs checked. `integration/vip-webhook` leads with `security` because signature verification is its riskiest code. + +To add a lens, extend `ALLOWED_LENSES` in `src/scaffolds/schema.js` — `validate.js` and the schema-parity test read it from there. + +--- + ## Features (`feature{}`) — toggleable scaffolds An optional `feature` block turns a scaffold into something `wp-tooling features` can switch on and @@ -313,6 +332,8 @@ Use nesting when a scaffold has multiple variants of the same concept (PHPCS sta `wp` holds the framework-shaped kinds (a CPT, a REST controller, a CLI command). `wp-api` holds scaffolds that customise a **modern WordPress core API** — code whose shape is dictated by core's own hooks and which must be guarded against the WordPress version that introduced them (`wp-api/speculation`, Speculation Rules, WP 6.8; `wp-api/block-bindings` and `wp-api/script-module`, both WP 6.5). Those pair with `"wizard_step": "wp-apis"`. +`integration` holds opinionated integrations with a specific platform or service, where the value is encoding that platform's rules rather than a WordPress primitive (`integration/vip-search`, `integration/vip-remote-request`, `integration/vip-webhook`). Each builds on a `wp` kind and reuses its wiring anchor — `vip-search` wires where `wp/registrable` does, `vip-webhook` where `wp/rest` does — and must degrade to working, standard behaviour off the platform instead of fataling. They pair with `"wizard_step": "integrations"`. Prefer a scaffold here only when the output is code the project owns and keeps; configuring an existing plugin, or an ongoing sync, is skill-shaped rather than scaffold-shaped. + A block stays in `wp` even when its behaviour comes from a modern core API: `wp/block-interactive` uses the Interactivity API, but what it generates is a block directory, so it sits beside `wp/block-dynamic` and shares its wiring anchor rather than opening a `block` category. --- @@ -425,6 +446,11 @@ Look at these existing scaffolds when authoring a new one: | Block with `block.json` + framework class | `wp/block-dynamic` | | Block with `render.php` + a `viewScriptModule` store | `wp/block-interactive` | | Workflow / YAML scaffold with secrets | `ci/cd-wporg` | +| Platform-specific code that no-ops or falls back off the platform | `integration/vip-search`, `integration/vip-remote-request` | +| Service class with no wiring, instantiated by its callers | `integration/vip-remote-request` | +| Input constrained to values a coding standard accepts (`enum`) | `integration/vip-remote-request` (`timeout`, `cache_ttl`) | +| Hook and constant names prefixed from the project's text domain | `integration/vip-*` (`hook_prefix` / `secret_prefix` derived via `input:text_domain`) | +| Extending a `wp` kind and reusing its wiring anchor | `integration/vip-search` (`wp/registrable`), `integration/vip-webhook` (`wp/rest`) | | Scaffold hosted in another repo (sources + index) | `scaffolds/sources.json` + `tests/fixtures/scaffolds-sources/sources.json` | Copy the closest match, rename, adjust. Most scaffolds are 20-50 lines of JSON plus one template file plus a test stub. diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/scaffold.json b/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/scaffold.json new file mode 100644 index 0000000..cc9c86e --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/scaffold.json @@ -0,0 +1,86 @@ +{ + "slug": "vip-remote-request", + "category": "integration", + "name": "VIP-safe remote request", + "description": "Adds a service that fetches remote data over GET without an uncached request on the page path. On WordPress VIP it delegates to wpcom_vip_file_get_contents(), which caches the body, serves a stale copy when the remote end fails and backs off a failing host; elsewhere it caches successful responses in the object cache and fetches with wp_safe_remote_get(). The timeout and cache TTL are constrained to values VIP's coding standards accept.", + "source": "template", + "wizard_step": "integrations", + "lens": ["vip-readiness", "performance", "security"], + "inputs": [ + { + "key": "namespace", + "description": "PSR-4 namespace for the service class. Default 'Inc\\Services' matches the rtCamp skeleton.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Services" + }, + { + "key": "base_path", + "description": "Directory for the service file. Default 'includes/Services'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "includes/Services" + }, + { + "key": "tests_namespace", + "description": "PSR-4 namespace for the test class. Default 'Inc\\Tests\\Services'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Tests\\Services" + }, + { + "key": "tests_path", + "description": "Directory for the test file. Default 'tests/Services'.", + "discover_from": "composer.json:autoload-dev.psr-4", + "default": "tests/Services" + }, + { + "key": "name", + "description": "Logical client name, e.g. 'events-feed'. Normalised to a PascalCase class name and used as the cache group.", + "required": true + }, + { + "key": "class", + "description": "PascalCase class name, derived from name.", + "discover_from": "input:name", + "transform": "pascal-case" + }, + { + "key": "cache_group", + "description": "Object cache group for responses fetched outside VIP, derived from name.", + "discover_from": "input:name", + "transform": "kebab-case" + }, + { + "key": "text_domain", + "description": "Plugin or theme slug, used to namespace the object cache so another package cannot collide with it.", + "discover_from": "plugin-header:text-domain", + "default": "my-plugin" + }, + { + "key": "timeout", + "enum": ["1", "2", "3"], + "description": "Request timeout in seconds, written into the TIMEOUT constant. Capped at 3, the most WordPressVIPMinimum.Performance.RemoteRequestTimeout accepts.", + "default": "3" + }, + { + "key": "cache_ttl", + "enum": ["300", "900", "1800", "3600", "21600", "86400"], + "description": "Seconds a successful response is cached, written into the CACHE_TTL constant. No lower than 300, the least WordPressVIPMinimum.Performance.LowExpiryCacheTime accepts.", + "default": "900" + } + ], + "files": [ + { + "src": "templates/remote-request.php.mustache", + "dest": "{{base_path}}/{{class}}.php" + } + ], + "tests": [ + { + "src": "templates/test.php.mustache", + "dest": "{{tests_path}}/{{class}}Test.php", + "framework": "phpunit" + } + ], + "composer_dependencies": { + "rtcamp/wp-primitives": "^2.0" + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/remote-request.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/remote-request.php.mustache new file mode 100644 index 0000000..63dccb2 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/remote-request.php.mustache @@ -0,0 +1,103 @@ +cache = $cache ?? new Cache( '{{text_domain}}' ); + } + + /** + * Fetch a URL over GET and return its body. + * + * @param string $url Absolute URL to fetch. + * @param array $args Extra wp_remote_get() arguments, such as headers. The timeout is always TIMEOUT, and internal hosts are always refused. + * @return string|null The response body, or null when the request failed and no cached copy exists. + */ + public function get( string $url, array $args = [] ): ?string { + if ( function_exists( 'wpcom_vip_file_get_contents' ) ) { + $http_api_args = array_merge( $args, [ 'reject_unsafe_urls' => true ] ); + $body = wpcom_vip_file_get_contents( $url, self::TIMEOUT, self::CACHE_TTL, [ 'http_api_args' => $http_api_args ] ); + + return is_string( $body ) ? $body : null; + } + + $key = md5( (string) wp_json_encode( [ $url, $args ] ) ); + $found = false; + $body = $this->cache->get( $key, self::CACHE_GROUP, false, $found ); + + if ( $found && is_string( $body ) ) { + return $body; + } + + $response = wp_safe_remote_get( $url, array_merge( $args, [ 'timeout' => self::TIMEOUT ] ) ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return null; + } + + $body = wp_remote_retrieve_body( $response ); + $this->cache->set( $key, $body, self::CACHE_GROUP, self::CACHE_TTL ); + + return $body; + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/test.php.mustache new file mode 100644 index 0000000..319b524 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/test.php.mustache @@ -0,0 +1,152 @@ +assertGreaterThanOrEqual( 1, {{class}}::TIMEOUT ); + $this->assertLessThanOrEqual( 3, {{class}}::TIMEOUT ); + $this->assertGreaterThanOrEqual( 300, {{class}}::CACHE_TTL ); + } + + /** + * A successful body is returned, then served from cache without a second request. + */ + public function test_caches_a_successful_response(): void { + $requests = 0; + add_filter( + 'pre_http_request', + static function () use ( &$requests ): array { + ++$requests; + + return self::response( 200 ); + } + ); + + $client = new {{class}}(); + + $this->assertSame( 'payload', $client->get( self::URL ) ); + $this->assertSame( 'payload', $client->get( self::URL ) ); + $this->assertSame( 1, $requests ); + } + + /** + * A failed request returns null and is not cached, so the next call tries again. + */ + public function test_does_not_cache_a_failure(): void { + if ( function_exists( 'wpcom_vip_file_get_contents' ) ) { + $this->markTestSkipped( 'On VIP a failing host is backed off by design; this covers the non-VIP path.' ); + } + + // The first request fails and the second succeeds. + $codes = [ 500, 200 ]; + add_filter( + 'pre_http_request', + static function () use ( &$codes ): array { + return self::response( (int) array_shift( $codes ) ); + } + ); + + $client = new {{class}}(); + + $this->assertNull( $client->get( self::URL ) ); + $this->assertSame( 'payload', $client->get( self::URL ) ); + } + + /** + * Every request carries the scaffolded timeout, even when the caller asks for a longer one. + */ + public function test_requests_use_the_scaffolded_timeout(): void { + $timeout = null; + add_filter( + 'pre_http_request', + static function ( $preempt, array $args ) use ( &$timeout ) { + $timeout = $args['timeout']; + + return false === $preempt ? self::response( 200 ) : $preempt; + }, + 10, + 2 + ); + + ( new {{class}}() )->get( self::URL, [ 'timeout' => {{class}}::TIMEOUT * 10 ] ); + + $this->assertSame( {{class}}::TIMEOUT, $timeout ); + } + + /** + * An internal host is refused before any connection is made, on VIP and off it. + */ + public function test_refuses_an_internal_host(): void { + $checked = []; + add_filter( + 'http_request_host_is_external', + static function ( $external, string $host ) use ( &$checked ) { + $checked[] = $host; + + return $external; + }, + 10, + 2 + ); + + // VIP's helper reports every failed request as a notice. + set_error_handler( static fn (): bool => true, E_USER_NOTICE ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Silences only that notice, for this call. + + try { + $body = ( new {{class}}() )->get( 'http://127.0.0.1/feed' ); + } finally { + restore_error_handler(); + } + + $this->assertNull( $body ); + $this->assertSame( [ '127.0.0.1' ], $checked ); + } + + /** + * A response in the shape the HTTP API returns, carrying the body 'payload'. + * + * @param int $code HTTP status code. + * @return array The response. + */ + private static function response( int $code ): array { + return [ + 'headers' => [], + 'body' => 'payload', + 'response' => [ + 'code' => $code, + 'message' => '', + ], + 'cookies' => [], + 'filename' => null, + ]; + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-search/scaffold.json b/node-packages/wp-tooling/scaffolds/integration/vip-search/scaffold.json new file mode 100644 index 0000000..963c97d --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-search/scaffold.json @@ -0,0 +1,93 @@ +{ + "slug": "vip-search", + "category": "integration", + "name": "VIP Search query offload", + "description": "Adds a Registrable that sends heavy meta and taxonomy queries on one post type to VIP Search (or any ElasticPress backend) through the 'ep_integrate' query var. Adds the queried meta keys to VIP Search's post meta allow list, which indexes none by default, and fires an action whenever an offloaded query silently falls back to MySQL: integration not enabled for the environment, VIP search rate limiting, or an index error. No-ops when no ElasticPress backend is loaded.", + "source": "template", + "wizard_step": "integrations", + "lens": ["vip-readiness", "performance"], + "inputs": [ + { + "key": "namespace", + "description": "PSR-4 namespace for the service class. Default 'Inc\\Services' matches the rtCamp skeleton.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Services" + }, + { + "key": "base_path", + "description": "Directory for the service file. Default 'includes/Services'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "includes/Services" + }, + { + "key": "tests_namespace", + "description": "PSR-4 namespace for the test class. Default 'Inc\\Tests\\Services'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Tests\\Services" + }, + { + "key": "tests_path", + "description": "Directory for the test file. Default 'tests/Services'.", + "discover_from": "composer.json:autoload-dev.psr-4", + "default": "tests/Services" + }, + { + "key": "name", + "description": "Logical service name, e.g. 'event-search'. Normalised to a PascalCase class name and used in the fallback action name.", + "required": true + }, + { + "key": "class", + "description": "PascalCase class name, derived from name.", + "discover_from": "input:name", + "transform": "pascal-case" + }, + { + "key": "post_type", + "description": "Post type whose heavy queries are offloaded, written into the POST_TYPE constant.", + "default": "post" + }, + { + "key": "text_domain", + "description": "Plugin or theme slug. Only used to prefix the fallback action name.", + "discover_from": "plugin-header:text-domain", + "default": "my-plugin" + }, + { + "key": "hook_prefix", + "description": "Prefix for the fallback action name, derived from text_domain.", + "discover_from": "input:text_domain", + "transform": "snake-case" + }, + { + "key": "hook_name", + "description": "Service part of the fallback action name, derived from name.", + "discover_from": "input:name", + "transform": "snake-case" + } + ], + "files": [ + { + "src": "templates/vip-search.php.mustache", + "dest": "{{base_path}}/{{class}}.php" + } + ], + "wiring": [ + { + "target_file": "{{base_path}}/../Modules/Services.php", + "anchor": "// scaffold:wp/registrable:classes", + "snippet_template": "\\{{namespace}}\\{{class}}::class,", + "description": "Add the class to a Services module so the framework Loader instantiates it and calls register_hooks(). If no suitable module exists, scaffold one first via `wp-tooling add wp/module --name=Services --kind=registrable`." + } + ], + "tests": [ + { + "src": "templates/test.php.mustache", + "dest": "{{tests_path}}/{{class}}Test.php", + "framework": "phpunit" + } + ], + "composer_dependencies": { + "rtcamp/wp-primitives": "^2.0" + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/test.php.mustache new file mode 100644 index 0000000..abd7338 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/test.php.mustache @@ -0,0 +1,191 @@ +assertInstanceOf( Registrable::class, new {{class}}() ); + } + + /** + * The query hooks are bound if, and only if, an ElasticPress backend is loaded. + */ + public function test_register_hooks_binds_only_with_a_search_backend(): void { + $search = new {{class}}(); + $search->register_hooks(); + + $backend_loaded = defined( 'EP_VERSION' ); + $offload_bound = false !== has_action( 'pre_get_posts', [ $search, 'offload' ] ); + $reporting_bound = false !== has_filter( 'posts_pre_query', [ $search, 'report_fallback' ] ); + + $this->assertSame( $backend_loaded, $offload_bound ); + $this->assertSame( $backend_loaded, $reporting_bound ); + } + + /** + * A main query on the post type that filters by meta is sent to the index. + */ + public function test_offloads_a_filtered_main_query(): void { + add_action( + 'pre_get_posts', + static function ( WP_Query $query ): void { + if ( $query->is_main_query() ) { + self::filter_by_meta( $query, {{class}}::POST_TYPE ); + } + }, + 9 + ); + add_action( 'pre_get_posts', [ new {{class}}(), 'offload' ] ); + + $this->go_to( home_url( '/' ) ); + + $this->assertTrue( $GLOBALS['wp_query']->get( 'ep_integrate' ) ); + } + + /** + * A term archive URL leaves post_type empty and filters through the parsed + * tax_query, never the tax_query query var. It is offloaded all the same. + */ + public function test_offloads_a_term_archive_of_the_post_type(): void { + if ( ! post_type_exists( {{class}}::POST_TYPE ) ) { + register_post_type( {{class}}::POST_TYPE, [ 'public' => true ] ); + } + register_taxonomy( 'offload_test_genre', {{class}}::POST_TYPE, [ 'public' => true ] ); + self::factory()->term->create( + [ + 'taxonomy' => 'offload_test_genre', + 'slug' => 'fiction', + ] + ); + add_action( 'pre_get_posts', [ new {{class}}(), 'offload' ] ); + + $this->go_to( add_query_arg( 'offload_test_genre', 'fiction', home_url( '/' ) ) ); + + $this->assertTrue( $GLOBALS['wp_query']->is_tax( 'offload_test_genre' ) ); + $this->assertTrue( $GLOBALS['wp_query']->get( 'ep_integrate' ) ); + } + + /** + * Category and tag archives query posts, and a search queries every type. + */ + public function test_resolves_the_post_type_an_archive_queries(): void { + $search = new {{class}}(); + $category = new WP_Query(); + $searched = new WP_Query(); + + $category->parse_query( 'cat=1' ); + $searched->parse_query( 's=book&cat=1' ); + + $this->assertSame( 'post' === {{class}}::POST_TYPE, $search->should_offload( $category ) ); + $this->assertFalse( $search->should_offload( $searched ) ); + } + + /** + * A secondary query is never changed, however it is filtered: it opts in where it is built. + */ + public function test_leaves_secondary_queries_alone(): void { + $query = new WP_Query(); + self::filter_by_meta( $query, {{class}}::POST_TYPE ); + + ( new {{class}}() )->offload( $query ); + + $this->assertSame( '', $query->get( 'ep_integrate' ) ); + } + + /** + * Only meta or taxonomy queries on the post type are picked. + */ + public function test_should_offload_picks_filtered_queries_on_the_post_type(): void { + $search = new {{class}}(); + $filtered = new WP_Query(); + $other_type = new WP_Query(); + $unfiltered = new WP_Query(); + + self::filter_by_meta( $filtered, {{class}}::POST_TYPE ); + self::filter_by_meta( $other_type, {{class}}::POST_TYPE . '-other' ); + $unfiltered->set( 'post_type', {{class}}::POST_TYPE ); + + $this->assertTrue( $search->should_offload( $filtered ) ); + $this->assertFalse( $search->should_offload( $other_type ) ); + $this->assertFalse( $search->should_offload( $unfiltered ) ); + } + + /** + * A query that asked for the index is announced only when the index did not answer. + */ + public function test_reports_only_a_real_fallback(): void { + $fallbacks = []; + add_action( + {{class}}::FALLBACK_ACTION, + static function ( WP_Query $query ) use ( &$fallbacks ): void { + $fallbacks[] = $query; + } + ); + + $search = new {{class}}(); + $query = new WP_Query(); + $query->set( 'ep_integrate', true ); + + $this->assertSame( [], $search->report_fallback( [], $query ) ); + $this->assertNull( $search->report_fallback( null, $query ) ); + $this->assertSame( [ $query ], $fallbacks ); + } + + /** + * META_KEYS are added in whichever format the allow list arrived in. + */ + public function test_meta_allow_list_keeps_its_format(): void { + $search = new {{class}}(); + + $as_list = $search->filter_meta_allow_list( [ 'existing_key' ] ); + $as_map = $search->filter_meta_allow_list( [ 'existing_key' => true ] ); + + $this->assertSame( array_values( array_unique( array_merge( [ 'existing_key' ], {{class}}::META_KEYS ) ) ), $as_list ); + $this->assertSame( array_merge( [ 'existing_key' => true ], array_fill_keys( {{class}}::META_KEYS, true ) ), $as_map ); + $this->assertSame( 'not-an-array', $search->filter_meta_allow_list( 'not-an-array' ) ); + } + + /** + * Point a query at a post type and filter it by meta. + * + * @param \WP_Query $query The query to change. + * @param string $post_type Post type to query. + */ + private static function filter_by_meta( WP_Query $query, string $post_type ): void { + $query->set( 'post_type', $post_type ); + $query->set( + 'meta_query', + [ + [ + 'key' => 'example_key', + 'value' => 'example', + ], + ] + ); + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/vip-search.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/vip-search.php.mustache new file mode 100644 index 0000000..6565726 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/vip-search.php.mustache @@ -0,0 +1,196 @@ + true, ... ] )`, and the main query is + * opted in here when should_offload() picks it. Three things make the offload + * quieter than it looks, and this class covers each: + * + * - Only the main query is changed from pre_get_posts. Changing every query + * there would move admin screens, widgets and feeds with it. + * - VIP Search indexes no post meta by default, so a meta query against a key + * missing from META_KEYS matches nothing in the index. + * - A query sent to the index can still run against MySQL: when query + * integration is not enabled for the environment, when VIP rate-limits + * search, or when the index errors. That happens silently, so it is + * announced on FALLBACK_ACTION instead, for queries opted in either way. + */ +final class {{class}} implements Registrable { + + /** + * Post type whose heavy queries are offloaded. + */ + public const POST_TYPE = '{{post_type}}'; + + /** + * Post meta keys the index must carry for meta queries to match, e.g. [ 'event_date' ]. + * + * @var string[] + */ + public const META_KEYS = []; + + /** + * Action fired with the WP_Query when a query sent to the index falls back to MySQL. + * + * Listen on this constant. report_fallback() fires the same name as a + * literal, so coding standards can check its prefix. + */ + public const FALLBACK_ACTION = '{{hook_prefix}}_{{hook_name}}_search_fallback'; + + /** + * Bind the query hooks. Called by the framework Loader. + * + * No-ops without an ElasticPress backend, which VIP Search also loads. + */ + public function register_hooks(): void { + if ( ! defined( 'EP_VERSION' ) ) { + return; + } + + add_action( 'pre_get_posts', [ $this, 'offload' ] ); + add_filter( 'posts_pre_query', [ $this, 'report_fallback' ], 20, 2 ); + add_filter( 'vip_search_post_meta_allow_list', [ $this, 'filter_meta_allow_list' ] ); + } + + /** + * Whether the main query should be answered by the search index. + * + * Picks front-end queries on POST_TYPE that filter by meta or taxonomy, the + * shape MySQL handles worst on large tables, term archives included: their + * URL fills the parsed tax_query, never the tax_query query var. Narrow it + * to your own archives. + * + * @param \WP_Query $query The query about to run. + * @return bool True to send the query to the index. + */ + public function should_offload( WP_Query $query ): bool { + if ( is_admin() || self::POST_TYPE !== $this->queried_post_type( $query ) ) { + return false; + } + + $filters_by_term = ! empty( $query->get( 'tax_query' ) ) + || ( $query->tax_query instanceof WP_Tax_Query && ! empty( $query->tax_query->queries ) ); + + return ! empty( $query->get( 'meta_query' ) ) || $filters_by_term; + } + + /** + * The one post type the query fetches, or an empty string for several or any. + * + * An archive URL leaves post_type empty for WP_Query to fill in later, so + * this resolves it the way WP_Query does: any type on a search, the + * searchable types using the queried taxonomies on a custom taxonomy + * archive, and post on every other listing, category and tag archives + * included. + * + * @param \WP_Query $query The query about to run. + * @return string The post type, or an empty string. + */ + private function queried_post_type( WP_Query $query ): string { + $post_type = $query->get( 'post_type' ); + + if ( is_array( $post_type ) ) { + return 1 === count( $post_type ) ? (string) reset( $post_type ) : ''; + } + + if ( ! empty( $post_type ) ) { + return (string) $post_type; + } + + if ( $query->is_search() ) { + return ''; + } + + if ( $query->is_tax() ) { + $taxonomies = $query->tax_query instanceof WP_Tax_Query ? array_keys( $query->tax_query->queried_terms ) : []; + $post_types = array_filter( + get_post_types( [ 'exclude_from_search' => false ] ), + static fn ( string $type ): bool => [] !== array_intersect( $taxonomies, get_object_taxonomies( $type ) ) + ); + + return 1 === count( $post_types ) ? (string) reset( $post_types ) : ''; + } + + if ( $query->is_attachment() ) { + return 'attachment'; + } + + return $query->is_page() ? 'page' : 'post'; + } + + /** + * Send the main query to the index when should_offload() picks it. + * + * @param \WP_Query $query The query about to run. + */ + public function offload( WP_Query $query ): void { + if ( ! $query->is_main_query() || ! $this->should_offload( $query ) ) { + return; + } + + $query->set( 'ep_integrate', true ); + } + + /** + * Announce a query sent to the index that the index did not answer. + * + * Runs after ElasticPress, which hands the index's posts to this filter on + * success and leaves them null when integration is skipped or the index + * errors. A null here, for a query that asked for the index, means MySQL + * runs it next. + * + * @param array|null $posts Posts supplied so far, or null to let MySQL run the query. + * @param \WP_Query $query The query about to run. + * @return array|null The posts, untouched. + */ + public function report_fallback( $posts, WP_Query $query ) { + if ( null === $posts && true === $query->get( 'ep_integrate' ) ) { + do_action( '{{hook_prefix}}_{{hook_name}}_search_fallback', $query ); + } + + return $posts; + } + + /** + * Add META_KEYS to the post meta VIP Search indexes. + * + * The allow list arrives either as a list of keys or as a key => true map, + * and VIP discards entries that do not follow the format they arrived in. + * + * @param mixed $allow_list Meta keys already allowed. + * @return mixed The allow list with META_KEYS added in the same format. Anything but an array is returned untouched. + */ + public function filter_meta_allow_list( mixed $allow_list ): mixed { + if ( ! is_array( $allow_list ) ) { + return $allow_list; + } + + if ( array_is_list( $allow_list ) ) { + return array_values( array_unique( array_merge( $allow_list, self::META_KEYS ) ) ); + } + + return array_merge( $allow_list, array_fill_keys( self::META_KEYS, true ) ); + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json new file mode 100644 index 0000000..c298b63 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json @@ -0,0 +1,127 @@ +{ + "slug": "vip-webhook", + "category": "integration", + "name": "VIP-safe webhook receiver", + "description": "Adds a REST controller extending AbstractRESTController from rtcamp/wp-primitives that receives signed JSON webhooks the way they survive at scale: it verifies an HMAC-SHA256 signature over the raw body in constant time, refuses every delivery until a shared secret is configured, caps the body size, acknowledges a retried delivery without queueing it twice, and hands the payload to a queue (Action Scheduler when loaded, WP-Cron otherwise) instead of doing the work inside the request. The secret is read from a VIP environment variable, or a constant of the same name elsewhere; it is never written by the scaffold.", + "source": "template", + "wizard_step": "integrations", + "lens": ["security", "vip-readiness", "performance"], + "inputs": [ + { + "key": "namespace", + "description": "PSR-4 namespace for the controller class. Default 'Inc\\Rest' matches the rtCamp skeleton and wp/rest.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Rest" + }, + { + "key": "base_path", + "description": "Directory for the controller file. Default 'includes/Rest'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "includes/Rest" + }, + { + "key": "module_path", + "description": "REST module file the controller is listed in. Default 'includes/Modules/Rest.php', the file `wp/module --name=Rest` creates. Pass the project's own file with its exact case, e.g. 'inc/Modules/REST.php' in features-plugin-skeleton: on a case-sensitive filesystem Rest.php is a different file.", + "discover_from": "composer.json:autoload.psr-4", + "default": "includes/Modules/Rest.php" + }, + { + "key": "tests_namespace", + "description": "PSR-4 namespace for the test class. Default 'Inc\\Tests\\Rest'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Tests\\Rest" + }, + { + "key": "tests_path", + "description": "Directory for the test file. Default 'tests/Rest'.", + "discover_from": "composer.json:autoload-dev.psr-4", + "default": "tests/Rest" + }, + { + "key": "name", + "description": "Webhook source, e.g. 'orders' or 'crm-contacts'. Used to derive the class, the route segment, the queued hook and the secret's name.", + "required": true + }, + { + "key": "class", + "description": "PascalCase class name prefix, derived from name. The controller is {class}WebhookController.", + "discover_from": "input:name", + "transform": "pascal-case" + }, + { + "key": "route", + "description": "Route segment after /webhooks/, derived from name.", + "discover_from": "input:name", + "transform": "kebab-case" + }, + { + "key": "rest_namespace", + "description": "REST namespace prefix, e.g. 'myplugin'. The route becomes /wp-json/{rest_namespace}/v{rest_version}/webhooks/{route}.", + "required": true + }, + { + "key": "rest_version", + "description": "API version number, e.g. '1'.", + "default": "1" + }, + { + "key": "signature_header", + "description": "Request header carrying the hex HMAC-SHA256 of the raw body. Match your sender; adapt verify_signature() if it prefixes the digest or signs a timestamp too.", + "default": "x-signature" + }, + { + "key": "text_domain", + "description": "i18n text domain for error messages. Also prefixes the queued hook and the secret's name.", + "discover_from": "plugin-header:text-domain", + "default": "my-plugin" + }, + { + "key": "hook_prefix", + "description": "Prefix for the queued hook, derived from text_domain.", + "discover_from": "input:text_domain", + "transform": "snake-case" + }, + { + "key": "hook_name", + "description": "Source part of the queued hook, derived from name.", + "discover_from": "input:name", + "transform": "snake-case" + }, + { + "key": "secret_prefix", + "description": "Prefix for the secret's environment variable or constant name, derived from text_domain.", + "discover_from": "input:text_domain", + "transform": "upper-snake-case" + }, + { + "key": "secret_name", + "description": "Source part of the secret's name, derived from name.", + "discover_from": "input:name", + "transform": "upper-snake-case" + } + ], + "files": [ + { + "src": "templates/webhook.php.mustache", + "dest": "{{base_path}}/{{class}}WebhookController.php" + } + ], + "wiring": [ + { + "target_file": "{{module_path}}", + "anchor": "// scaffold:wp/rest:classes", + "snippet_template": "\\{{namespace}}\\{{class}}WebhookController::class,", + "description": "Add the controller to the REST module so the framework Loader registers its route on rest_api_init. Edit the module file that exists, matching its case; if the project has none, scaffold it first via `wp-tooling add wp/module --name=Rest --kind=rest`. Deliveries are refused until the secret named by the controller's SECRET_NAME constant is set: as a VIP environment variable on VIP, or a constant in wp-config.php elsewhere. Process payloads in a callback hooked to the controller's HOOK, which receives the decoded payload as its only argument." + } + ], + "tests": [ + { + "src": "templates/test.php.mustache", + "dest": "{{tests_path}}/{{class}}WebhookControllerTest.php", + "framework": "phpunit" + } + ], + "composer_dependencies": { + "rtcamp/wp-primitives": "^2.0" + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/test.php.mustache new file mode 100644 index 0000000..69213a4 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/test.php.mustache @@ -0,0 +1,170 @@ +register_hooks(); + do_action( 'rest_api_init' ); + + $routes = rest_get_server()->get_routes(); + $route = '/{{rest_namespace}}/v{{rest_version}}/webhooks/{{route}}'; + + $this->assertArrayHasKey( $route, $routes ); + $this->assertSame( [ 'POST' => true ], $routes[ $route ][0]['methods'] ); + } + + /** + * Until a secret is set, every delivery is refused, even a signed one. + */ + public function test_refuses_deliveries_until_a_secret_is_set(): void { + $request = $this->signed_request( '{"id":1}' ); + + $this->assertFalse( ( new {{class}}WebhookController() )->verify_signature( $request ) ); + } + + /** + * A delivery signed with the secret is accepted; one whose body changed is not. + */ + public function test_verifies_the_signature_over_the_raw_body(): void { + $controller = $this->controller(); + $request = $this->signed_request( '{"id":1}' ); + + $this->assertTrue( $controller->verify_signature( $request ) ); + + $request->set_body( '{"id":2}' ); + + $this->assertFalse( $controller->verify_signature( $request ) ); + } + + /** + * A body that is not a JSON object or array is rejected with a 400. + */ + public function test_rejects_a_body_that_is_not_a_json_object_or_array(): void { + foreach ( [ 'not json', '"a string"' ] as $body ) { + $response = $this->controller()->receive( $this->signed_request( $body ) ); + + $this->assertInstanceOf( \WP_Error::class, $response ); + $this->assertSame( 400, $response->get_error_data()['status'] ); + } + } + + /** + * A batch posted as a JSON array is queued like any other delivery. + */ + public function test_queues_a_batch_posted_as_a_json_array(): void { + $response = $this->controller()->receive( $this->signed_request( '[{"id":1},{"id":2}]' ) ); + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertSame( 202, $response->get_status() ); + } + + /** + * A body over the size cap is rejected with a 413. + */ + public function test_rejects_an_oversized_body(): void { + $body = '"' . str_repeat( 'a', {{class}}WebhookController::MAX_BODY_BYTES ) . '"'; + $response = $this->controller()->receive( $this->signed_request( $body ) ); + + $this->assertInstanceOf( \WP_Error::class, $response ); + $this->assertSame( 413, $response->get_error_data()['status'] ); + } + + /** + * A delivery is queued once; the same delivery again is acknowledged but not queued twice. + */ + public function test_queues_a_delivery_once(): void { + $controller = $this->controller(); + $request = $this->signed_request( '{"id":1}' ); + + $first = $controller->receive( $request ); + $second = $controller->receive( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $first ); + $this->assertSame( 202, $first->get_status() ); + $this->assertInstanceOf( WP_REST_Response::class, $second ); + $this->assertSame( 200, $second->get_status() ); + + $in_wp_cron = false !== wp_next_scheduled( {{class}}WebhookController::HOOK, [ [ 'id' => 1 ] ] ); + $uses_wp_cron_queue = ! function_exists( 'as_enqueue_async_action' ); + + $this->assertSame( $uses_wp_cron_queue, $in_wp_cron ); + } + + /** + * A retry arrives in a later request, after the runtime object cache is + * gone. It must still be acknowledged, not queued again or refused with 503. + */ + public function test_acknowledges_a_retry_in_a_later_request(): void { + $request = $this->signed_request( '{"id":3}' ); + $first = $this->controller()->receive( $request ); + + wp_cache_flush_runtime(); + + $retry = $this->controller()->receive( $request ); + + $this->assertInstanceOf( WP_REST_Response::class, $first ); + $this->assertSame( 202, $first->get_status() ); + $this->assertInstanceOf( WP_REST_Response::class, $retry ); + $this->assertSame( 200, $retry->get_status() ); + } + + /** + * A controller whose shared secret is SECRET. + */ + private function controller(): {{class}}WebhookController { + return new class() extends {{class}}WebhookController { + + /** + * {@inheritDoc} + */ + protected function get_secret(): string { + return {{class}}WebhookControllerTest::SECRET; + } + }; + } + + /** + * A POST delivery to the webhook route, signed with SECRET. + * + * @param string $body Raw request body. + * @return \WP_REST_Request The signed request. + */ + private function signed_request( string $body ): WP_REST_Request { + $request = new WP_REST_Request( 'POST', '/{{rest_namespace}}/v{{rest_version}}/webhooks/{{route}}' ); + $request->set_body( $body ); + $request->set_header( {{class}}WebhookController::SIGNATURE_HEADER, hash_hmac( 'sha256', $body, self::SECRET ) ); + + return $request; + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/webhook.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/webhook.php.mustache new file mode 100644 index 0000000..ef947c3 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/webhook.php.mustache @@ -0,0 +1,226 @@ +namespace}/v{$this->version}", + '/' . $this->rest_base, + [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'receive' ], + 'permission_callback' => [ $this, 'verify_signature' ], + ], + ] + ); + } + + /** + * Accept only deliveries signed with the shared secret. + * + * The signature is compared in constant time. Adapt the parsing to your + * sender: some prefix the digest, such as 'sha256=', or sign a timestamp + * with the body so an old delivery cannot be replayed. + * + * @param \WP_REST_Request $request The incoming delivery. + * @return bool True when the signature matches. + */ + public function verify_signature( WP_REST_Request $request ): bool { + $secret = $this->get_secret(); + $signature = (string) $request->get_header( self::SIGNATURE_HEADER ); + + if ( '' === $secret || '' === $signature ) { + return false; + } + + return hash_equals( hash_hmac( 'sha256', $request->get_body(), $secret ), $signature ); + } + + /** + * Queue a verified delivery and acknowledge it. + * + * A delivery already queued within the last day is acknowledged without + * being queued again, since senders retry anything they did not see + * acknowledged. A delivery is identified by HOOK and body, so another + * webhook receiving the same body still queues it. + * + * @param \WP_REST_Request $request The verified delivery. + * @return \WP_REST_Response|\WP_Error 202 when queued, 200 for a delivery already queued, or an error. + */ + public function receive( WP_REST_Request $request ): WP_REST_Response|\WP_Error { + $body = $request->get_body(); + + if ( strlen( $body ) > self::MAX_BODY_BYTES ) { + return new \WP_Error( 'rest_payload_too_large', __( 'The webhook payload is too large.', '{{text_domain}}' ), [ 'status' => 413 ] ); + } + + // An object, or an array for senders that post events in batches. + $payload = json_decode( $body, true ); + + if ( ! is_array( $payload ) ) { + return new \WP_Error( 'rest_invalid_json', __( 'The webhook payload is not a JSON object or array.', '{{text_domain}}' ), [ 'status' => 400 ] ); + } + + $delivery = hash( 'sha256', self::HOOK . ':' . $body ); + + if ( ! $this->claim_delivery( $delivery ) ) { + return new WP_REST_Response( [ 'status' => 'duplicate' ], 200 ); + } + + if ( ! $this->queue( $payload ) ) { + $this->release_delivery( $delivery ); + + return new \WP_Error( 'rest_webhook_not_queued', __( 'The webhook could not be queued. Retry later.', '{{text_domain}}' ), [ 'status' => 503 ] ); + } + + return new WP_REST_Response( [ 'status' => 'queued' ], 202 ); + } + + /** + * Record a delivery for a day so a retry of it is acknowledged, not queued again. + * + * A persistent object cache, such as VIP's, gives an atomic add. Without + * one the object cache lasts only for the current request, so the record + * goes in a transient instead, or every retry would be queued again. + * + * @param string $delivery Hash identifying the delivery. + * @return bool False when the delivery is already recorded. + */ + private function claim_delivery( string $delivery ): bool { + if ( wp_using_ext_object_cache() ) { + return wp_cache_add( $delivery, true, self::CACHE_GROUP, DAY_IN_SECONDS ); + } + + if ( false !== get_transient( self::CACHE_GROUP . '_' . $delivery ) ) { + return false; + } + + return set_transient( self::CACHE_GROUP . '_' . $delivery, 1, DAY_IN_SECONDS ); + } + + /** + * Forget a delivery that could not be queued, so the sender's retry is. + * + * @param string $delivery Hash identifying the delivery. + */ + private function release_delivery( string $delivery ): void { + if ( wp_using_ext_object_cache() ) { + wp_cache_delete( $delivery, self::CACHE_GROUP ); + + return; + } + + delete_transient( self::CACHE_GROUP . '_' . $delivery ); + } + + /** + * Queue the payload on HOOK, through Action Scheduler when it is loaded and WP-Cron otherwise. + * + * @param array $payload The decoded payload. + * @return bool True when the payload was queued. + */ + protected function queue( array $payload ): bool { + if ( function_exists( 'as_enqueue_async_action' ) ) { + return 0 < as_enqueue_async_action( self::HOOK, [ $payload ] ); + } + + return true === wp_schedule_single_event( time(), self::HOOK, [ $payload ] ); + } + + /** + * Read the shared secret. + * + * @return string The secret, or an empty string when none is set. + */ + protected function get_secret(): string { + $secret = function_exists( 'vip_get_env_var' ) ? (string) vip_get_env_var( self::SECRET_NAME, '' ) : ''; + + if ( '' === $secret && defined( self::SECRET_NAME ) ) { + $secret = (string) constant( self::SECRET_NAME ); + } + + return $secret; + } +} diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md index 4aed337..2f53ed0 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md @@ -80,6 +80,9 @@ Files group by **kind**, never by feature. `` = project's autoload root (e | `wp-api/block-bindings` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | | `wp-api/script-module` | `includes/Services/` + `src/js/modules/` + `build/js/modules/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | | `wp/block-interactive` | `includes/Blocks/` + `src/blocks//` + `build/blocks//` | `\Blocks` | `tests/Blocks/` | `\Tests\Blocks` | `\Modules\Blocks` | +| `integration/vip-search` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | +| `integration/vip-remote-request` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | none - a service its callers instantiate | +| `integration/vip-webhook` | `includes/Rest/` | `\Rest` | `tests/Rest/` | `\Tests\Rest` | `\Modules\Rest` - pass `--module_path` when the project names the file differently, e.g. `REST.php` | `utility/*` is absent from the table on purpose: those are `source: package`, so they have no source dir, no test dir and no module. Each returns one accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. Never add a framework `Utils\*` class to a module or to `Main::CLASSES` — they implement neither `Registrable` nor `Shareable`, so the Loader would construct them with the wrong (or a missing) constructor argument. @@ -105,6 +108,9 @@ Write a test-case checklist covering: - `wp/block-interactive`: `register_hooks()` defers to `init`, the block dir points at the build output (never the source tree), and registration no-ops before WP 6.5 or before a build. Do **not** assert the block is registered - the build output is gitignored, so that fails on a clean checkout. Also confirm `block.json` still declares `supports.interactivity`: without it core never processes the `data-wp-*` directives and the block silently does nothing. - `wp-api/block-bindings`: the source registers on `init` only when the API exists, `SOURCE_NAME` is a name core accepts (namespace prefix, lowercase alphanumerics and dashes), `WP_Block_Bindings_Registry` reports it registered, and `get_value()` returns `null` - not an empty string - when there is nothing to resolve. Context-dependent cases need a block type declaring the source's `uses_context`, since `WP_Block` exposes nothing else. - `wp-api/script-module`: both hooks bound only when the API exists, `MODULE_ID` follows core's `@vendor/name` import-map convention, string dependencies normalise to `[ 'id' => ... ]` form, and the enqueued module prints with a URL inside the build directory. + - `integration/vip-search`: hooks bound only when an ElasticPress backend is loaded (`EP_VERSION`), only the **main** query is changed from `pre_get_posts` (secondary queries opt in with `'ep_integrate' => true` where they are built), `should_offload()` picks only meta or taxonomy queries on the post type, term archive URLs included (their `post_type` is empty and their terms sit in the parsed `tax_query`, not the query var), the fallback action fires for a null result and never for posts the index returned, and `META_KEYS` join VIP's meta allow list in the format it arrived in. The fallback is the point: without query integration enabled for the environment, or under VIP search rate limiting, `ep_integrate` silently runs on MySQL. + - `integration/vip-remote-request`: `TIMEOUT` stays within 1-3 and `CACHE_TTL` at 300 or more, a successful body is served from cache without a second request, a failure is never cached, and the scaffolded timeout wins over a caller's. Mock with `pre_http_request`; do not hook `http_request_args` (a VIP restricted hook) or pass a literal timeout above 3, both of which fail `WordPress-VIP-Go`. + - `integration/vip-webhook`: the route accepts POST only, a delivery is refused until a secret is set, the signature is checked over the raw body (a changed body fails), a body that is neither a JSON object nor an array gets 400 and an oversized one 413, and a repeated delivery to the same webhook is acknowledged with 200 without being queued twice. Override `get_secret()` in an anonymous subclass rather than defining the real constant. The engine's shipped test file already covers **Integration** for a plain instance of the kind (e.g. `post_type_exists()` for `wp/cpt` ships written and passing, not as a stub) — list it to confirm coverage, not to write it. Your effort in §7 goes to **Happy path / Edge cases / Error paths**: brief-specific behaviour the engine can't know. @@ -171,7 +177,7 @@ Frameworks per kind: | Kind | Framework | |---|---| -| `wp/cpt`, `wp/taxonomy`, `wp/cron`, `wp/cli`, `wp/rest`, `wp/shortcode`, `wp/admin-page`, `wp/settings-page`, `wp/user-role`, `wp/registrable`, `wp-api/speculation`, `wp-api/block-bindings`, `wp-api/script-module` | PHPUnit | +| `wp/cpt`, `wp/taxonomy`, `wp/cron`, `wp/cli`, `wp/rest`, `wp/shortcode`, `wp/admin-page`, `wp/settings-page`, `wp/user-role`, `wp/registrable`, `wp-api/speculation`, `wp-api/block-bindings`, `wp-api/script-module`, `integration/vip-search`, `integration/vip-remote-request`, `integration/vip-webhook` | PHPUnit | | `wp/block-dynamic` | Jest (edit.js) + PHPUnit (render method) | | `wp/block-interactive` | PHPUnit (registrar). The `view.js` store is not unit-tested: `@wordpress/interactivity` ships ESM only, which the `@wordpress/jest-preset-default` CJS setup cannot resolve. | | `ci/*` | actionlint + yaml-parse | diff --git a/node-packages/wp-tooling/skills/scaffold/SKILL.md b/node-packages/wp-tooling/skills/scaffold/SKILL.md index 4aed337..2f53ed0 100644 --- a/node-packages/wp-tooling/skills/scaffold/SKILL.md +++ b/node-packages/wp-tooling/skills/scaffold/SKILL.md @@ -80,6 +80,9 @@ Files group by **kind**, never by feature. `` = project's autoload root (e | `wp-api/block-bindings` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | | `wp-api/script-module` | `includes/Services/` + `src/js/modules/` + `build/js/modules/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | | `wp/block-interactive` | `includes/Blocks/` + `src/blocks//` + `build/blocks//` | `\Blocks` | `tests/Blocks/` | `\Tests\Blocks` | `\Modules\Blocks` | +| `integration/vip-search` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | `\Modules\Services` | +| `integration/vip-remote-request` | `includes/Services/` | `\Services` | `tests/Services/` | `\Tests\Services` | none - a service its callers instantiate | +| `integration/vip-webhook` | `includes/Rest/` | `\Rest` | `tests/Rest/` | `\Tests\Rest` | `\Modules\Rest` - pass `--module_path` when the project names the file differently, e.g. `REST.php` | `utility/*` is absent from the table on purpose: those are `source: package`, so they have no source dir, no test dir and no module. Each returns one accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. Never add a framework `Utils\*` class to a module or to `Main::CLASSES` — they implement neither `Registrable` nor `Shareable`, so the Loader would construct them with the wrong (or a missing) constructor argument. @@ -105,6 +108,9 @@ Write a test-case checklist covering: - `wp/block-interactive`: `register_hooks()` defers to `init`, the block dir points at the build output (never the source tree), and registration no-ops before WP 6.5 or before a build. Do **not** assert the block is registered - the build output is gitignored, so that fails on a clean checkout. Also confirm `block.json` still declares `supports.interactivity`: without it core never processes the `data-wp-*` directives and the block silently does nothing. - `wp-api/block-bindings`: the source registers on `init` only when the API exists, `SOURCE_NAME` is a name core accepts (namespace prefix, lowercase alphanumerics and dashes), `WP_Block_Bindings_Registry` reports it registered, and `get_value()` returns `null` - not an empty string - when there is nothing to resolve. Context-dependent cases need a block type declaring the source's `uses_context`, since `WP_Block` exposes nothing else. - `wp-api/script-module`: both hooks bound only when the API exists, `MODULE_ID` follows core's `@vendor/name` import-map convention, string dependencies normalise to `[ 'id' => ... ]` form, and the enqueued module prints with a URL inside the build directory. + - `integration/vip-search`: hooks bound only when an ElasticPress backend is loaded (`EP_VERSION`), only the **main** query is changed from `pre_get_posts` (secondary queries opt in with `'ep_integrate' => true` where they are built), `should_offload()` picks only meta or taxonomy queries on the post type, term archive URLs included (their `post_type` is empty and their terms sit in the parsed `tax_query`, not the query var), the fallback action fires for a null result and never for posts the index returned, and `META_KEYS` join VIP's meta allow list in the format it arrived in. The fallback is the point: without query integration enabled for the environment, or under VIP search rate limiting, `ep_integrate` silently runs on MySQL. + - `integration/vip-remote-request`: `TIMEOUT` stays within 1-3 and `CACHE_TTL` at 300 or more, a successful body is served from cache without a second request, a failure is never cached, and the scaffolded timeout wins over a caller's. Mock with `pre_http_request`; do not hook `http_request_args` (a VIP restricted hook) or pass a literal timeout above 3, both of which fail `WordPress-VIP-Go`. + - `integration/vip-webhook`: the route accepts POST only, a delivery is refused until a secret is set, the signature is checked over the raw body (a changed body fails), a body that is neither a JSON object nor an array gets 400 and an oversized one 413, and a repeated delivery to the same webhook is acknowledged with 200 without being queued twice. Override `get_secret()` in an anonymous subclass rather than defining the real constant. The engine's shipped test file already covers **Integration** for a plain instance of the kind (e.g. `post_type_exists()` for `wp/cpt` ships written and passing, not as a stub) — list it to confirm coverage, not to write it. Your effort in §7 goes to **Happy path / Edge cases / Error paths**: brief-specific behaviour the engine can't know. @@ -171,7 +177,7 @@ Frameworks per kind: | Kind | Framework | |---|---| -| `wp/cpt`, `wp/taxonomy`, `wp/cron`, `wp/cli`, `wp/rest`, `wp/shortcode`, `wp/admin-page`, `wp/settings-page`, `wp/user-role`, `wp/registrable`, `wp-api/speculation`, `wp-api/block-bindings`, `wp-api/script-module` | PHPUnit | +| `wp/cpt`, `wp/taxonomy`, `wp/cron`, `wp/cli`, `wp/rest`, `wp/shortcode`, `wp/admin-page`, `wp/settings-page`, `wp/user-role`, `wp/registrable`, `wp-api/speculation`, `wp-api/block-bindings`, `wp-api/script-module`, `integration/vip-search`, `integration/vip-remote-request`, `integration/vip-webhook` | PHPUnit | | `wp/block-dynamic` | Jest (edit.js) + PHPUnit (render method) | | `wp/block-interactive` | PHPUnit (registrar). The `view.js` store is not unit-tested: `@wordpress/interactivity` ships ESM only, which the `@wordpress/jest-preset-default` CJS setup cannot resolve. | | `ci/*` | actionlint + yaml-parse | diff --git a/node-packages/wp-tooling/src/scaffolds/list.js b/node-packages/wp-tooling/src/scaffolds/list.js index 7144e11..de0eca4 100644 --- a/node-packages/wp-tooling/src/scaffolds/list.js +++ b/node-packages/wp-tooling/src/scaffolds/list.js @@ -97,6 +97,10 @@ function makeId(s) { return s.category ? `${s.category}/${s.slug}` : s.slug; } +function lensOf(s) { + return Array.isArray(s.lens) ? [...s.lens] : null; +} + function applyFilters(scaffolds, opts) { let out = scaffolds; if (opts.category) { @@ -121,6 +125,8 @@ function summarise(scaffold) { description: scaffold.description, kind: 'template', origin: 'remote', + // Like `counts`, the lens lives in the manifest `list` never fetches. + lens: null, counts: null, // Remote manifests are not fetched during `list`, so the full input // schema is unknown until `add` hydrates them (mirrors `counts: null`). @@ -135,6 +141,7 @@ function summarise(scaffold) { description: scaffold.description, kind: scaffold.source === 'package' ? 'package' : 'template', origin: scaffold.origin, + lens: lensOf(scaffold), counts: { inputs: Array.isArray(scaffold.inputs) ? scaffold.inputs.length : 0, wiring: Array.isArray(scaffold.wiring) ? scaffold.wiring.length : 0, diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 66dc867..c7644c3 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -519,6 +519,9 @@ class ScaffoldRegistry { // template scaffolds — the AI orchestrator doesn't need to // branch on where the body came from; both render Mustache. kind: scaffold.source === 'package' ? 'package' : 'template', + // Lens skills to run once the new code's tests are green, or + // `null` to leave the choice to the orchestrating skill. + lens: lensOf(scaffold), dryRun, }, engine: { @@ -1312,6 +1315,10 @@ function makeId(scaffold) { : scaffold.slug; } +function lensOf(scaffold) { + return Array.isArray(scaffold.lens) ? [...scaffold.lens] : null; +} + function makeKey(scaffold) { return `${scaffold.category || ''}/${scaffold.slug}`; } diff --git a/node-packages/wp-tooling/src/scaffolds/schema.js b/node-packages/wp-tooling/src/scaffolds/schema.js index a62819a..b1a0b61 100644 --- a/node-packages/wp-tooling/src/scaffolds/schema.js +++ b/node-packages/wp-tooling/src/scaffolds/schema.js @@ -34,6 +34,23 @@ const ALLOWED_SOURCES = ['template', 'package']; */ const ALLOWED_WIZARD_STEPS = ['modules', 'integrations', 'wp-apis', null]; +/** + * Lens skills a scaffold's output can be checked with once its tests are + * green. Each names a lens *skill* (`skills//` in wp-dev-tools), never + * an MCP ability: a lens decides for itself which abilities to read. Closed, + * so a typo fails at authoring time instead of silently skipping the check. + * A lens named here need not be installed in every project; the orchestrating + * skill runs the ones it finds. + */ +const ALLOWED_LENSES = [ + 'accessibility', + 'i18n', + 'performance', + 'security', + 'seo', + 'vip-readiness', +]; + /** Test frameworks the engine recognises. Closed enum to catch typos. */ const ALLOWED_TEST_FRAMEWORKS = [ 'phpunit', @@ -250,6 +267,14 @@ const SCAFFOLD_SCHEMA = { description: 'Wizard group, or `null` for `add`-only invocation.', enum: ALLOWED_WIZARD_STEPS, }, + lens: { + type: 'array', + description: + "Lens skills to check this scaffold's output with once its tests go green, most relevant first. Omit to let the orchestrating skill choose.", + items: { type: 'string', enum: ALLOWED_LENSES }, + minItems: 1, + uniqueItems: true, + }, module_class: { type: 'string', description: @@ -306,6 +331,7 @@ module.exports = { REQUIRED_FIELDS, ALLOWED_SOURCES, ALLOWED_WIZARD_STEPS, + ALLOWED_LENSES, ALLOWED_TEST_FRAMEWORKS, ALLOWED_SECRET_SCOPES, ALLOWED_INPUT_TRANSFORMS, diff --git a/node-packages/wp-tooling/src/scaffolds/validate.js b/node-packages/wp-tooling/src/scaffolds/validate.js index 3d32c03..2cb6806 100644 --- a/node-packages/wp-tooling/src/scaffolds/validate.js +++ b/node-packages/wp-tooling/src/scaffolds/validate.js @@ -29,6 +29,7 @@ const { REQUIRED_FIELDS, ALLOWED_SOURCES, ALLOWED_WIZARD_STEPS, + ALLOWED_LENSES, ALLOWED_TEST_FRAMEWORKS, ALLOWED_SECRET_SCOPES, ALLOWED_INPUT_TRANSFORMS, @@ -173,6 +174,9 @@ function validate(scaffold) { ).join(', ')}` ); } + if (scaffold.lens !== undefined) { + errors.push(...validateLens(scaffold.lens)); + } if (scaffold.files !== undefined) { errors.push(...validateFiles(scaffold.files)); @@ -205,6 +209,24 @@ function validate(scaffold) { return errors; } +function validateLens(lens) { + if (!Array.isArray(lens) || lens.length === 0) { + return ['lens: must be a non-empty array of lens names']; + } + const errors = []; + lens.forEach((name, i) => { + if (!ALLOWED_LENSES.includes(name)) { + errors.push( + `lens[${i}]: must be one of ${ALLOWED_LENSES.join(', ')}` + ); + } + }); + if (new Set(lens).size !== lens.length) { + errors.push('lens: must not contain duplicates'); + } + return errors; +} + function validateFiles(files) { const errors = []; if (!Array.isArray(files)) { diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index e075506..4310eb7 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -14,6 +14,10 @@ * - the modern WP API scaffolds (wp/block-interactive, wp-api/block-bindings, * wp-api/script-module) render every file of their layout, in order, and * reuse an existing wiring anchor rather than minting a new one + * - the VIP integration scaffolds (integration/vip-*) declare their lens, + * derive every prefixed name from the project's text domain, reuse the + * wiring anchor of the primitive they build on, and reject a timeout or + * TTL outside the values VIP accepts * - utility/* are source: package — zero files, a Composer dep and one * accessor snippet, with context_slug discovered from composer.json:name * - lint/phpcs/full and lint/phpcs/core extend rtCampWP and rtCampWP-Basic, @@ -917,6 +921,151 @@ function makeIncProject() { return target; } +/** + * The error a dry run of a bundled scaffold throws. + * + * @param {string} id Scaffold id. + * @param {Object} inputs Inputs to run it with. + * @return {Promise} The error; fails the test if nothing is thrown. + */ +async function rejection(id, inputs) { + return registry + .execute(id, inputs, { dryRun: true, cwd: makeTmpDir() }) + .then( + () => { + throw new Error('should have thrown'); + }, + (caught) => caught + ); +} + +describe('integration/vip-remote-request', () => { + it('renders a Services class with VIP-safe defaults and no wiring', async () => { + const result = await registry.execute( + 'integration/vip-remote-request', + { name: 'events-feed' }, + { dryRun: true, cwd: makeIncProject() } + ); + expect(result.scaffold.lens).toEqual([ + 'vip-readiness', + 'performance', + 'security', + ]); + expect(result.engine.inputs).toMatchObject({ + namespace: 'Acme\\Blog\\Services', + text_domain: 'acme-blog', + cache_group: 'events-feed', + timeout: '3', + cache_ttl: '900', + }); + expect(result.engine.wrote).toEqual(['inc/Services/EventsFeed.php']); + expect(result.ai.tests[0].path).toBe( + 'tests/php/Services/EventsFeedTest.php' + ); + // A plain service, instantiated by its callers: nothing to wire. + expect(result.ai.wiring).toEqual([]); + }); + + it('rejects a timeout above the 3 seconds VIP accepts', async () => { + const err = await rejection('integration/vip-remote-request', { + name: 'events-feed', + timeout: '5', + }); + expect(err.code).toBe('EINVALIDINPUT'); + expect(err.invalid).toEqual([ + { key: 'timeout', value: '5', allowed: ['1', '2', '3'] }, + ]); + }); + + it('rejects a cache TTL below the 300 seconds VIP accepts', async () => { + const err = await rejection('integration/vip-remote-request', { + name: 'events-feed', + cache_ttl: '60', + }); + expect(err.code).toBe('EINVALIDINPUT'); + expect(err.invalid[0]).toMatchObject({ key: 'cache_ttl', value: '60' }); + }); +}); + +describe('integration/vip-search', () => { + it('renders a Services class, prefixes its fallback action and reuses the registrable anchor', async () => { + const result = await registry.execute( + 'integration/vip-search', + { name: 'event-search', post_type: 'event' }, + { dryRun: true, cwd: makeIncProject() } + ); + expect(result.scaffold.lens).toEqual(['vip-readiness', 'performance']); + expect(result.engine.inputs).toMatchObject({ + post_type: 'event', + hook_prefix: 'acme_blog', + hook_name: 'event_search', + }); + expect(result.engine.wrote).toEqual(['inc/Services/EventSearch.php']); + const wiring = result.ai.wiring[0]; + expect(wiring.targetFile).toBe('inc/Modules/Services.php'); + expect(wiring.anchor).toBe('// scaffold:wp/registrable:classes'); + }); +}); + +describe('integration/vip-webhook', () => { + it('renders a Rest controller with a prefixed hook and secret name', async () => { + const result = await registry.execute( + 'integration/vip-webhook', + { name: 'crm-contacts', rest_namespace: 'acme' }, + { dryRun: true, cwd: makeIncProject() } + ); + expect(result.scaffold.lens).toEqual([ + 'security', + 'vip-readiness', + 'performance', + ]); + expect(result.engine.inputs).toMatchObject({ + class: 'CrmContacts', + route: 'crm-contacts', + hook_prefix: 'acme_blog', + hook_name: 'crm_contacts', + secret_prefix: 'ACME_BLOG', + secret_name: 'CRM_CONTACTS', + signature_header: 'x-signature', + }); + expect(result.engine.wrote).toEqual([ + 'inc/Rest/CrmContactsWebhookController.php', + ]); + expect(result.ai.wiring[0].anchor).toBe('// scaffold:wp/rest:classes'); + // The secret is a developer action, never rendered into a file. + expect(result.developer.secrets).toEqual([]); + }); + + it('wires into the REST module file the project names', async () => { + const target = async (cwd, inputs = {}) => + ( + await registry.execute( + 'integration/vip-webhook', + { name: 'crm-contacts', rest_namespace: 'acme', ...inputs }, + { dryRun: true, cwd } + ) + ).ai.wiring[0].targetFile; + + // The file `wp/module --name=Rest` creates, under the PSR-4 root. + expect(await target(makeTmpDir())).toBe('includes/Modules/Rest.php'); + expect(await target(makeIncProject())).toBe('inc/Modules/Rest.php'); + // features-plugin-skeleton's module is REST.php, a different file on Linux. + expect( + await target(makeIncProject(), { + base_path: 'inc/Modules/REST', + module_path: 'inc/Modules/REST.php', + }) + ).toBe('inc/Modules/REST.php'); + }); + + it('requires a REST namespace', async () => { + const err = await rejection('integration/vip-webhook', { + name: 'crm-contacts', + }); + expect(err.message).toMatch(/rest_namespace/); + }); +}); + describe.each([ ['lint/phpcs/full', 'rtCampWP'], ['lint/phpcs/core', 'rtCampWP-Basic'], diff --git a/node-packages/wp-tooling/tests/scaffolds/list.test.js b/node-packages/wp-tooling/tests/scaffolds/list.test.js index 5fdb212..e743098 100644 --- a/node-packages/wp-tooling/tests/scaffolds/list.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/list.test.js @@ -130,6 +130,8 @@ describe('list command, --json output', () => { category: 'wp', kind: 'template', origin: 'default', + // wp/cpt declares no lens, so the orchestrating skill chooses. + lens: null, }); expect(typeof cpt.name).toBe('string'); expect(typeof cpt.description).toBe('string'); @@ -143,6 +145,23 @@ describe('list command, --json output', () => { expect(cpt.counts.wiring).toBeGreaterThanOrEqual(1); }); + it('exposes a declared lens so an orchestrating skill can pick its check', async () => { + const cwd = makeTmpDir(); + const { code, stdout } = await withStdoutCapture(() => + list.runCli(['--cwd', cwd, '--json', '--category=integration']) + ); + expect(code).toBe(0); + const parsed = JSON.parse(stdout.trim()); + const webhook = parsed.scaffolds.find( + (s) => s.id === 'integration/vip-webhook' + ); + expect(webhook.lens).toEqual([ + 'security', + 'vip-readiness', + 'performance', + ]); + }); + it('reports secrets for the WPORG workflow scaffold', async () => { const cwd = makeTmpDir(); const { code, stdout } = await withStdoutCapture(() => diff --git a/node-packages/wp-tooling/tests/scaffolds/registry.test.js b/node-packages/wp-tooling/tests/scaffolds/registry.test.js index cc2b37a..9e8d851 100644 --- a/node-packages/wp-tooling/tests/scaffolds/registry.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/registry.test.js @@ -247,6 +247,7 @@ describe('execute() result shape', () => { id: 'wp/cli', slug: 'cli', kind: 'template', + lens: null, dryRun: true, }); expect(result.engine.wrote).toEqual(['includes/Cli/QmExport.php']); diff --git a/node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js b/node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js index 8427b40..f896896 100644 --- a/node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js @@ -46,6 +46,7 @@ const everyKey = () => ({ description: 'Adds a CLI command.', source: 'template', wizard_step: null, + lens: ['performance'], module_class: 'Inc\\Cli\\Command', files: [{ src: 'a.mustache', dest: 'a.php', raw: false }], inputs: [ diff --git a/node-packages/wp-tooling/tests/scaffolds/validate.test.js b/node-packages/wp-tooling/tests/scaffolds/validate.test.js index 6d6f1ec..a3c6062 100644 --- a/node-packages/wp-tooling/tests/scaffolds/validate.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/validate.test.js @@ -74,6 +74,42 @@ describe('validate base scaffold', () => { }); }); +describe('validate lens', () => { + it('accepts an array of known lens skills', () => { + expect( + validate({ ...baseValid(), lens: ['performance', 'security'] }) + ).toEqual([]); + }); + + it('rejects a bare string, so a single lens is still an array', () => { + expect(validate({ ...baseValid(), lens: 'performance' })).toEqual([ + 'lens: must be a non-empty array of lens names', + ]); + }); + + it('rejects an empty array', () => { + expect(validate({ ...baseValid(), lens: [] })).toEqual([ + 'lens: must be a non-empty array of lens names', + ]); + }); + + it('rejects an MCP ability name, naming the offending position', () => { + // runtime-health is an ability a lens reads, not a lens itself. + const errors = validate({ + ...baseValid(), + lens: ['performance', 'runtime-health'], + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/^lens\[1\]: must be one of /); + }); + + it('rejects duplicates', () => { + expect( + validate({ ...baseValid(), lens: ['security', 'security'] }) + ).toEqual(['lens: must not contain duplicates']); + }); +}); + describe('validate files block', () => { it('rejects non-array files', () => { const errors = validate({ ...baseValid(), files: 'nope' });