From 92058cc048bf8b131101ce76e0ffbccfdf8fff14 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 17 Sep 2026 12:35:12 +0530 Subject: [PATCH 1/2] feat: add VIP integration scaffolds and lens infrastructure Add four new integration scaffolds for WordPress VIP Go projects: - vip-cron: WP-Cron/Action Scheduler dual-runner with Cron Control support - vip-remote-request: cached HTTP client using wpcom_vip_file_get_contents - vip-search: ElasticPress offload for meta/taxonomy queries with fallback - vip-webhook: HMAC-SHA256 verified webhook receiver with deduplication --- node-packages/wp-tooling/CHANGELOG.md | 9 + .../wp-tooling/docs/ai-orchestration.md | 4 +- .../wp-tooling/docs/authoring-scaffolds.md | 26 +++ .../integration/vip-cron/scaffold.json | 94 +++++++++ .../vip-cron/templates/test.php.mustache | 103 ++++++++++ .../vip-cron/templates/vip-cron.php.mustache | 126 ++++++++++++ .../vip-remote-request/scaffold.json | 86 ++++++++ .../templates/remote-request.php.mustache | 103 ++++++++++ .../templates/test.php.mustache | 152 ++++++++++++++ .../integration/vip-search/scaffold.json | 93 +++++++++ .../vip-search/templates/test.php.mustache | 153 ++++++++++++++ .../templates/vip-search.php.mustache | 146 +++++++++++++ .../integration/vip-webhook/scaffold.json | 127 ++++++++++++ .../vip-webhook/templates/test.php.mustache | 152 ++++++++++++++ .../templates/webhook.php.mustache | 192 ++++++++++++++++++ .../claude-skills/templates/scaffold-SKILL.md | 10 +- .../wp-tooling/skills/scaffold/SKILL.md | 10 +- .../wp-tooling/src/scaffolds/list.js | 7 + .../wp-tooling/src/scaffolds/registry.js | 7 + .../wp-tooling/src/scaffolds/schema.js | 26 +++ .../wp-tooling/src/scaffolds/validate.js | 22 ++ .../tests/scaffolds/bundled-manifests.test.js | 186 +++++++++++++++++ .../wp-tooling/tests/scaffolds/list.test.js | 19 ++ .../tests/scaffolds/registry.test.js | 1 + .../tests/scaffolds/schema-parity.test.js | 1 + .../tests/scaffolds/validate.test.js | 36 ++++ 26 files changed, 1888 insertions(+), 3 deletions(-) create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-cron/scaffold.json create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/test.php.mustache create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-remote-request/scaffold.json create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/remote-request.php.mustache create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-remote-request/templates/test.php.mustache create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-search/scaffold.json create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-search/templates/test.php.mustache create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-search/templates/vip-search.php.mustache create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/test.php.mustache create mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/webhook.php.mustache diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 2486894..d7cd208 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -6,6 +6,15 @@ 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`; 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-cron` scaffold — a recurring job on wp-primitives' `AbstractJob` that runs on exactly one runner. `AbstractJob`'s `schedule_*()` methods do nothing without Action Scheduler, so the job falls back to a WP-Cron event, which Cron Control runs on VIP, and clears that event for a single Action Scheduler action once the library loads. Checks its schedule on `admin_init` only, declares its Cron Control concurrency, and constrains `schedule` to the four WP-Cron schedules core registers. Distinct from `wp/cron`. +- `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, 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..8f4fd6f 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; `integration/vip-cron` leads with `vip-readiness` because Cron Control is what it exists to satisfy. + +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-cron`, `integration/vip-webhook`). Each builds on a `wp` kind and reuses its wiring anchor — `vip-cron` wires where `wp/cron` 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`), `integration/vip-cron` (`schedule`) | +| 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-cron` (`wp/cron`), `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-cron/scaffold.json b/node-packages/wp-tooling/scaffolds/integration/vip-cron/scaffold.json new file mode 100644 index 0000000..1e75e5a --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-cron/scaffold.json @@ -0,0 +1,94 @@ +{ + "slug": "vip-cron", + "category": "integration", + "name": "VIP-aware recurring job", + "description": "Adds a recurring background job on AbstractJob from rtcamp/wp-primitives that runs through Action Scheduler when it is loaded and through a WP-Cron event otherwise, which Cron Control runs on WordPress VIP. AbstractJob alone schedules nothing without Action Scheduler; this job falls back to WP-Cron and clears that event once Action Scheduler appears, so it always runs on exactly one runner. Checks its schedule on admin requests only and declares its Cron Control concurrency. Distinct from wp/cron, which is a bare wp_schedule_event() Registrable.", + "source": "template", + "wizard_step": "integrations", + "lens": ["vip-readiness", "performance"], + "inputs": [ + { + "key": "namespace", + "description": "PSR-4 namespace for the job class. Default 'Inc\\Cron' matches the rtCamp skeleton and wp/cron.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Cron" + }, + { + "key": "base_path", + "description": "Directory for the job file. Default 'includes/Cron'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "includes/Cron" + }, + { + "key": "tests_namespace", + "description": "PSR-4 namespace for the test class. Default 'Inc\\Tests\\Cron'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Tests\\Cron" + }, + { + "key": "tests_path", + "description": "Directory for the test file. Default 'tests/Cron'.", + "discover_from": "composer.json:autoload-dev.psr-4", + "default": "tests/Cron" + }, + { + "key": "name", + "description": "Job slug, kebab- or snake-case, e.g. 'sync-feed'. Used to derive the class and the hook name.", + "required": true + }, + { + "key": "class", + "description": "PascalCase class name, derived from name.", + "discover_from": "input:name", + "transform": "pascal-case" + }, + { + "key": "text_domain", + "description": "Plugin or theme slug. Only used to prefix the hook name.", + "discover_from": "plugin-header:text-domain", + "default": "my-plugin" + }, + { + "key": "hook_prefix", + "description": "Prefix for the hook name, derived from text_domain.", + "discover_from": "input:text_domain", + "transform": "snake-case" + }, + { + "key": "hook_name", + "description": "Job part of the hook name, derived from name.", + "discover_from": "input:name", + "transform": "snake-case" + }, + { + "key": "schedule", + "enum": ["hourly", "twicedaily", "daily", "weekly"], + "description": "How often the job runs: a WP-Cron schedule core registers, whose interval is also used for Action Scheduler.", + "default": "hourly" + } + ], + "files": [ + { + "src": "templates/vip-cron.php.mustache", + "dest": "{{base_path}}/{{class}}.php" + } + ], + "wiring": [ + { + "target_file": "{{base_path}}/../Modules/Cron.php", + "anchor": "// scaffold:wp/cron:classes", + "snippet_template": "\\{{namespace}}\\{{class}}::class,", + "description": "Add the job to the Cron module so the framework Loader calls register_hooks() during boot. If the Cron module does not exist yet, scaffold it first via `wp-tooling add wp/module --name=Cron --kind=cron`." + } + ], + "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-cron/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/test.php.mustache new file mode 100644 index 0000000..fc6013f --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/test.php.mustache @@ -0,0 +1,103 @@ +unschedule(); + + parent::tear_down(); + } + + /** + * The job is an AbstractJob, so the framework Loader boots it. + */ + public function test_extends_abstract_job(): void { + $this->assertInstanceOf( AbstractJob::class, new {{class}}() ); + } + + /** + * The job runs on its own hook. + */ + public function test_runs_on_its_hook(): void { + $this->assertSame( {{class}}::HOOK, {{class}}::get_hook() ); + } + + /** + * The schedule is one WP-Cron knows, so the event is never rejected. + */ + public function test_schedule_is_registered(): void { + $this->assertArrayHasKey( {{class}}::SCHEDULE, wp_get_schedules() ); + } + + /** + * Registering hooks binds the job, its schedule check and its concurrency filter. + */ + public function test_register_hooks_binds_the_job(): void { + $job = new {{class}}(); + $job->register_hooks(); + + $this->assertTrue( has_action( {{class}}::HOOK ) ); + $this->assertNotFalse( has_action( 'admin_init', [ $job, 'ensure_scheduled' ] ) ); + $this->assertNotFalse( has_filter( 'a8c_cron_control_concurrent_event_whitelist', [ $job, 'filter_concurrent_event_whitelist' ] ) ); + } + + /** + * The job ends up on exactly one runner: Action Scheduler when it is loaded, WP-Cron otherwise. + * + * Starts from a WP-Cron event, as a site has once Action Scheduler is + * installed after the job was first scheduled, and checks twice to prove + * the check does not schedule a second run. + */ + public function test_ensure_scheduled_uses_exactly_one_runner(): void { + wp_schedule_event( time(), {{class}}::SCHEDULE, {{class}}::HOOK ); + + $job = new {{class}}(); + $job->ensure_scheduled(); + $job->ensure_scheduled(); + + $via_action_scheduler = {{class}}::is_available(); + $via_wp_cron = false !== wp_next_scheduled( {{class}}::HOOK ); + + $this->assertSame( ! $via_action_scheduler, $via_wp_cron ); + $this->assertSame( $via_action_scheduler, $job->is_scheduled() ); + } + + /** + * The concurrency limit is added without dropping other plugins' entries. + */ + public function test_concurrency_limit_keeps_other_entries(): void { + $job = new {{class}}(); + $whitelist = $job->filter_concurrent_event_whitelist( [ 'other_hook' => 3 ] ); + + $this->assertIsArray( $whitelist ); + $this->assertSame( 3, $whitelist['other_hook'] ); + $this->assertSame( {{class}}::MAX_CONCURRENCY, $whitelist[ {{class}}::HOOK ] ); + $this->assertSame( 'not-an-array', $job->filter_concurrent_event_whitelist( 'not-an-array' ) ); + } +} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache new file mode 100644 index 0000000..286f05d --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache @@ -0,0 +1,126 @@ +is_scheduled() ) { + $this->schedule_recurring( time(), $this->get_interval() ); + } + } + + /** + * Tell Cron Control how many runs of this job may overlap. + * + * @param mixed $whitelist Hook => concurrency limit map. + * @return mixed The map with this job's limit set. Anything but an array is returned untouched. + */ + public function filter_concurrent_event_whitelist( mixed $whitelist ): mixed { + if ( ! is_array( $whitelist ) ) { + return $whitelist; + } + + $whitelist[ self::HOOK ] = self::MAX_CONCURRENCY; + + return $whitelist; + } + + /** + * Do one run of the job. + * + * @param array $args Arguments the run was scheduled with; empty for a recurring run. + */ + protected function handle( array $args ): void { + // Replace with the job's work. Keep a run short and batch anything long. + } + + /** + * Interval of the WP-Cron schedule, reused as the Action Scheduler recurrence. + * + * @return int Seconds between runs. + */ + private function get_interval(): int { + $schedules = wp_get_schedules(); + + return (int) ( $schedules[ self::SCHEDULE ]['interval'] ?? HOUR_IN_SECONDS ); + } +} 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..3364807 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/test.php.mustache @@ -0,0 +1,153 @@ +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 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..4beed65 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-search/templates/vip-search.php.mustache @@ -0,0 +1,146 @@ + 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. 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 !== $query->get( 'post_type' ) ) { + return false; + } + + return ! empty( $query->get( 'meta_query' ) ) || ! empty( $query->get( 'tax_query' ) ); + } + + /** + * 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..f0fe047 --- /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 job on the controller's HOOK, e.g. one from `wp-tooling add integration/vip-cron`." + } + ], + "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..a4c0150 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/test.php.mustache @@ -0,0 +1,152 @@ +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 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..be24f7e --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/templates/webhook.php.mustache @@ -0,0 +1,192 @@ +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. That memory lasts as + * long as the object cache keeps it, which without a persistent cache is + * the current request only. + * + * @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 ( ! wp_cache_add( $delivery, true, self::CACHE_GROUP, DAY_IN_SECONDS ) ) { + return new WP_REST_Response( [ 'status' => 'duplicate' ], 200 ); + } + + if ( ! $this->queue( $payload ) ) { + wp_cache_delete( $delivery, self::CACHE_GROUP ); + + 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 ); + } + + /** + * 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..0b09861 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,10 @@ 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-cron` | `includes/Cron/` | `\Cron` | `tests/Cron/` | `\Tests\Cron` | `\Modules\Cron` | +| `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 +109,10 @@ 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, 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-cron`: the job runs on exactly **one** runner - starting from an existing WP-Cron event, `ensure_scheduled()` keeps it without Action Scheduler and clears it for a single Action Scheduler action with it - checking twice schedules nothing extra, the schedule is one `wp_get_schedules()` knows, and the Cron Control concurrency filter keeps other plugins' entries. Assert the runner from `is_available()` rather than assuming one, so the case holds on both. + - `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 +179,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-cron`, `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..0b09861 100644 --- a/node-packages/wp-tooling/skills/scaffold/SKILL.md +++ b/node-packages/wp-tooling/skills/scaffold/SKILL.md @@ -80,6 +80,10 @@ 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-cron` | `includes/Cron/` | `\Cron` | `tests/Cron/` | `\Tests\Cron` | `\Modules\Cron` | +| `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 +109,10 @@ 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, 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-cron`: the job runs on exactly **one** runner - starting from an existing WP-Cron event, `ensure_scheduled()` keeps it without Action Scheduler and clears it for a single Action Scheduler action with it - checking twice schedules nothing extra, the schedule is one `wp_get_schedules()` knows, and the Cron Control concurrency filter keeps other plugins' entries. Assert the runner from `is_available()` rather than assuming one, so the case holds on both. + - `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 +179,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-cron`, `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..c57f2f8 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, TTL + * or schedule 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,188 @@ 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-cron', () => { + it('renders into the Cron layout, reuses the wp/cron anchor and asks for wp-primitives', async () => { + const result = await registry.execute( + 'integration/vip-cron', + { name: 'sync-feed' }, + { dryRun: true, cwd: makeIncProject() } + ); + expect(result.scaffold.lens).toEqual(['vip-readiness', 'performance']); + expect(result.engine.inputs).toMatchObject({ + namespace: 'Acme\\Blog\\Cron', + hook_prefix: 'acme_blog', + hook_name: 'sync_feed', + schedule: 'hourly', + }); + expect(result.engine.wrote).toEqual(['inc/Cron/SyncFeed.php']); + expect(result.ai.wiring[0].anchor).toBe('// scaffold:wp/cron:classes'); + expect(result.developer.install.composer).toEqual({ + 'rtcamp/wp-primitives': '^2.0', + }); + }); + + it('rejects a schedule WP-Cron does not register', async () => { + const err = await rejection('integration/vip-cron', { + name: 'sync-feed', + schedule: 'every_minute', + }); + expect(err.code).toBe('EINVALIDINPUT'); + expect(err.invalid).toEqual([ + { + key: 'schedule', + value: 'every_minute', + allowed: ['hourly', 'twicedaily', 'daily', 'weekly'], + }, + ]); + }); +}); + +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' }); From c4a814e8dd5bb862eccee2992b6a9801ab58dca1 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 8 Oct 2026 19:10:55 +0530 Subject: [PATCH 2/2] fix(scaffolds): VIP archive offload, webhook dedup, drop vip-cron --- node-packages/wp-tooling/CHANGELOG.md | 5 +- .../wp-tooling/docs/authoring-scaffolds.md | 8 +- .../integration/vip-cron/scaffold.json | 94 ------------- .../vip-cron/templates/test.php.mustache | 103 -------------- .../vip-cron/templates/vip-cron.php.mustache | 126 ------------------ .../vip-search/templates/test.php.mustache | 38 ++++++ .../templates/vip-search.php.mustache | 56 +++++++- .../integration/vip-webhook/scaffold.json | 2 +- .../vip-webhook/templates/test.php.mustache | 18 +++ .../templates/webhook.php.mustache | 52 ++++++-- .../claude-skills/templates/scaffold-SKILL.md | 6 +- .../wp-tooling/skills/scaffold/SKILL.md | 6 +- .../tests/scaffolds/bundled-manifests.test.js | 41 +----- 13 files changed, 165 insertions(+), 390 deletions(-) delete mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-cron/scaffold.json delete mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/test.php.mustache delete mode 100644 node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index d7cd208..d983a1b 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -10,10 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `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`; secondary queries opt in where they are built. No-ops without `EP_VERSION`. +- `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-cron` scaffold — a recurring job on wp-primitives' `AbstractJob` that runs on exactly one runner. `AbstractJob`'s `schedule_*()` methods do nothing without Action Scheduler, so the job falls back to a WP-Cron event, which Cron Control runs on VIP, and clears that event for a single Action Scheduler action once the library loads. Checks its schedule on `admin_init` only, declares its Cron Control concurrency, and constrains `schedule` to the four WP-Cron schedules core registers. Distinct from `wp/cron`. -- `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, 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. +- `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 diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index 8f4fd6f..0b11948 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -264,7 +264,7 @@ project may prefer. - **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; `integration/vip-cron` leads with `vip-readiness` because Cron Control is what it exists to satisfy. +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. @@ -332,7 +332,7 @@ 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-cron`, `integration/vip-webhook`). Each builds on a `wp` kind and reuses its wiring anchor — `vip-cron` wires where `wp/cron` 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. +`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. @@ -448,9 +448,9 @@ Look at these existing scaffolds when authoring a new one: | 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`), `integration/vip-cron` (`schedule`) | +| 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-cron` (`wp/cron`), `integration/vip-webhook` (`wp/rest`) | +| 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-cron/scaffold.json b/node-packages/wp-tooling/scaffolds/integration/vip-cron/scaffold.json deleted file mode 100644 index 1e75e5a..0000000 --- a/node-packages/wp-tooling/scaffolds/integration/vip-cron/scaffold.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "slug": "vip-cron", - "category": "integration", - "name": "VIP-aware recurring job", - "description": "Adds a recurring background job on AbstractJob from rtcamp/wp-primitives that runs through Action Scheduler when it is loaded and through a WP-Cron event otherwise, which Cron Control runs on WordPress VIP. AbstractJob alone schedules nothing without Action Scheduler; this job falls back to WP-Cron and clears that event once Action Scheduler appears, so it always runs on exactly one runner. Checks its schedule on admin requests only and declares its Cron Control concurrency. Distinct from wp/cron, which is a bare wp_schedule_event() Registrable.", - "source": "template", - "wizard_step": "integrations", - "lens": ["vip-readiness", "performance"], - "inputs": [ - { - "key": "namespace", - "description": "PSR-4 namespace for the job class. Default 'Inc\\Cron' matches the rtCamp skeleton and wp/cron.", - "discover_from": "composer.json:autoload.psr-4", - "default": "Inc\\Cron" - }, - { - "key": "base_path", - "description": "Directory for the job file. Default 'includes/Cron'.", - "discover_from": "composer.json:autoload.psr-4", - "default": "includes/Cron" - }, - { - "key": "tests_namespace", - "description": "PSR-4 namespace for the test class. Default 'Inc\\Tests\\Cron'.", - "discover_from": "composer.json:autoload.psr-4", - "default": "Inc\\Tests\\Cron" - }, - { - "key": "tests_path", - "description": "Directory for the test file. Default 'tests/Cron'.", - "discover_from": "composer.json:autoload-dev.psr-4", - "default": "tests/Cron" - }, - { - "key": "name", - "description": "Job slug, kebab- or snake-case, e.g. 'sync-feed'. Used to derive the class and the hook name.", - "required": true - }, - { - "key": "class", - "description": "PascalCase class name, derived from name.", - "discover_from": "input:name", - "transform": "pascal-case" - }, - { - "key": "text_domain", - "description": "Plugin or theme slug. Only used to prefix the hook name.", - "discover_from": "plugin-header:text-domain", - "default": "my-plugin" - }, - { - "key": "hook_prefix", - "description": "Prefix for the hook name, derived from text_domain.", - "discover_from": "input:text_domain", - "transform": "snake-case" - }, - { - "key": "hook_name", - "description": "Job part of the hook name, derived from name.", - "discover_from": "input:name", - "transform": "snake-case" - }, - { - "key": "schedule", - "enum": ["hourly", "twicedaily", "daily", "weekly"], - "description": "How often the job runs: a WP-Cron schedule core registers, whose interval is also used for Action Scheduler.", - "default": "hourly" - } - ], - "files": [ - { - "src": "templates/vip-cron.php.mustache", - "dest": "{{base_path}}/{{class}}.php" - } - ], - "wiring": [ - { - "target_file": "{{base_path}}/../Modules/Cron.php", - "anchor": "// scaffold:wp/cron:classes", - "snippet_template": "\\{{namespace}}\\{{class}}::class,", - "description": "Add the job to the Cron module so the framework Loader calls register_hooks() during boot. If the Cron module does not exist yet, scaffold it first via `wp-tooling add wp/module --name=Cron --kind=cron`." - } - ], - "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-cron/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/test.php.mustache deleted file mode 100644 index fc6013f..0000000 --- a/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/test.php.mustache +++ /dev/null @@ -1,103 +0,0 @@ -unschedule(); - - parent::tear_down(); - } - - /** - * The job is an AbstractJob, so the framework Loader boots it. - */ - public function test_extends_abstract_job(): void { - $this->assertInstanceOf( AbstractJob::class, new {{class}}() ); - } - - /** - * The job runs on its own hook. - */ - public function test_runs_on_its_hook(): void { - $this->assertSame( {{class}}::HOOK, {{class}}::get_hook() ); - } - - /** - * The schedule is one WP-Cron knows, so the event is never rejected. - */ - public function test_schedule_is_registered(): void { - $this->assertArrayHasKey( {{class}}::SCHEDULE, wp_get_schedules() ); - } - - /** - * Registering hooks binds the job, its schedule check and its concurrency filter. - */ - public function test_register_hooks_binds_the_job(): void { - $job = new {{class}}(); - $job->register_hooks(); - - $this->assertTrue( has_action( {{class}}::HOOK ) ); - $this->assertNotFalse( has_action( 'admin_init', [ $job, 'ensure_scheduled' ] ) ); - $this->assertNotFalse( has_filter( 'a8c_cron_control_concurrent_event_whitelist', [ $job, 'filter_concurrent_event_whitelist' ] ) ); - } - - /** - * The job ends up on exactly one runner: Action Scheduler when it is loaded, WP-Cron otherwise. - * - * Starts from a WP-Cron event, as a site has once Action Scheduler is - * installed after the job was first scheduled, and checks twice to prove - * the check does not schedule a second run. - */ - public function test_ensure_scheduled_uses_exactly_one_runner(): void { - wp_schedule_event( time(), {{class}}::SCHEDULE, {{class}}::HOOK ); - - $job = new {{class}}(); - $job->ensure_scheduled(); - $job->ensure_scheduled(); - - $via_action_scheduler = {{class}}::is_available(); - $via_wp_cron = false !== wp_next_scheduled( {{class}}::HOOK ); - - $this->assertSame( ! $via_action_scheduler, $via_wp_cron ); - $this->assertSame( $via_action_scheduler, $job->is_scheduled() ); - } - - /** - * The concurrency limit is added without dropping other plugins' entries. - */ - public function test_concurrency_limit_keeps_other_entries(): void { - $job = new {{class}}(); - $whitelist = $job->filter_concurrent_event_whitelist( [ 'other_hook' => 3 ] ); - - $this->assertIsArray( $whitelist ); - $this->assertSame( 3, $whitelist['other_hook'] ); - $this->assertSame( {{class}}::MAX_CONCURRENCY, $whitelist[ {{class}}::HOOK ] ); - $this->assertSame( 'not-an-array', $job->filter_concurrent_event_whitelist( 'not-an-array' ) ); - } -} diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache b/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache deleted file mode 100644 index 286f05d..0000000 --- a/node-packages/wp-tooling/scaffolds/integration/vip-cron/templates/vip-cron.php.mustache +++ /dev/null @@ -1,126 +0,0 @@ -is_scheduled() ) { - $this->schedule_recurring( time(), $this->get_interval() ); - } - } - - /** - * Tell Cron Control how many runs of this job may overlap. - * - * @param mixed $whitelist Hook => concurrency limit map. - * @return mixed The map with this job's limit set. Anything but an array is returned untouched. - */ - public function filter_concurrent_event_whitelist( mixed $whitelist ): mixed { - if ( ! is_array( $whitelist ) ) { - return $whitelist; - } - - $whitelist[ self::HOOK ] = self::MAX_CONCURRENCY; - - return $whitelist; - } - - /** - * Do one run of the job. - * - * @param array $args Arguments the run was scheduled with; empty for a recurring run. - */ - protected function handle( array $args ): void { - // Replace with the job's work. Keep a run short and batch anything long. - } - - /** - * Interval of the WP-Cron schedule, reused as the Action Scheduler recurrence. - * - * @return int Seconds between runs. - */ - private function get_interval(): int { - $schedules = wp_get_schedules(); - - return (int) ( $schedules[ self::SCHEDULE ]['interval'] ?? HOUR_IN_SECONDS ); - } -} 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 index 3364807..abd7338 100644 --- 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 @@ -67,6 +67,44 @@ final class {{class}}Test extends WP_UnitTestCase { $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. */ 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 index 4beed65..6565726 100644 --- 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 @@ -15,6 +15,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; use WP_Query; +use WP_Tax_Query; use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** @@ -77,17 +78,66 @@ final class {{class}} implements Registrable { * 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. Narrow it to your own archives. + * 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 !== $query->get( 'post_type' ) ) { + if ( is_admin() || self::POST_TYPE !== $this->queried_post_type( $query ) ) { return false; } - return ! empty( $query->get( 'meta_query' ) ) || ! empty( $query->get( 'tax_query' ) ); + $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'; } /** diff --git a/node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json index f0fe047..c298b63 100644 --- a/node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/integration/vip-webhook/scaffold.json @@ -111,7 +111,7 @@ "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 job on the controller's HOOK, e.g. one from `wp-tooling add integration/vip-cron`." + "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": [ 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 index a4c0150..69213a4 100644 --- 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 @@ -121,6 +121,24 @@ final class {{class}}WebhookControllerTest extends WP_UnitTestCase { $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. */ 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 index be24f7e..ef947c3 100644 --- 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 @@ -23,9 +23,8 @@ use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractRESTController; * Doing the work inside the request is what breaks webhooks under load: the * sender times out and retries, and on WordPress VIP long requests are cut * off. So the handler only verifies, de-duplicates and queues the payload on - * HOOK, then answers 202. Process it in a job listening on HOOK, such as one - * from `wp-tooling add integration/vip-cron`, which receives the decoded - * payload as its $args. + * HOOK, then answers 202. Process it in a callback hooked to HOOK, which + * receives the decoded payload as its only argument. * * The shared secret is read from the VIP environment variable named by * SECRET_NAME, or from a constant of that name elsewhere. Until one is set, @@ -56,7 +55,7 @@ class {{class}}WebhookController extends AbstractRESTController { public const MAX_BODY_BYTES = 65536; /** - * Object cache group remembering deliveries already queued. + * Object cache group, and transient prefix, remembering deliveries already queued. */ public const CACHE_GROUP = '{{hook_prefix}}_webhooks'; @@ -125,9 +124,7 @@ class {{class}}WebhookController extends AbstractRESTController { * 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. That memory lasts as - * long as the object cache keeps it, which without a persistent cache is - * the current request only. + * 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. @@ -148,12 +145,12 @@ class {{class}}WebhookController extends AbstractRESTController { $delivery = hash( 'sha256', self::HOOK . ':' . $body ); - if ( ! wp_cache_add( $delivery, true, self::CACHE_GROUP, DAY_IN_SECONDS ) ) { + if ( ! $this->claim_delivery( $delivery ) ) { return new WP_REST_Response( [ 'status' => 'duplicate' ], 200 ); } if ( ! $this->queue( $payload ) ) { - wp_cache_delete( $delivery, self::CACHE_GROUP ); + $this->release_delivery( $delivery ); return new \WP_Error( 'rest_webhook_not_queued', __( 'The webhook could not be queued. Retry later.', '{{text_domain}}' ), [ 'status' => 503 ] ); } @@ -161,6 +158,43 @@ class {{class}}WebhookController extends AbstractRESTController { 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. * 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 0b09861..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 @@ -82,7 +82,6 @@ Files group by **kind**, never by feature. `` = project's autoload root (e | `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-cron` | `includes/Cron/` | `\Cron` | `tests/Cron/` | `\Tests\Cron` | `\Modules\Cron` | | `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. @@ -109,9 +108,8 @@ 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, 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-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-cron`: the job runs on exactly **one** runner - starting from an existing WP-Cron event, `ensure_scheduled()` keeps it without Action Scheduler and clears it for a single Action Scheduler action with it - checking twice schedules nothing extra, the schedule is one `wp_get_schedules()` knows, and the Cron Control concurrency filter keeps other plugins' entries. Assert the runner from `is_available()` rather than assuming one, so the case holds on both. - `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. @@ -179,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`, `integration/vip-search`, `integration/vip-remote-request`, `integration/vip-cron`, `integration/vip-webhook` | 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 0b09861..2f53ed0 100644 --- a/node-packages/wp-tooling/skills/scaffold/SKILL.md +++ b/node-packages/wp-tooling/skills/scaffold/SKILL.md @@ -82,7 +82,6 @@ Files group by **kind**, never by feature. `` = project's autoload root (e | `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-cron` | `includes/Cron/` | `\Cron` | `tests/Cron/` | `\Tests\Cron` | `\Modules\Cron` | | `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. @@ -109,9 +108,8 @@ 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, 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-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-cron`: the job runs on exactly **one** runner - starting from an existing WP-Cron event, `ensure_scheduled()` keeps it without Action Scheduler and clears it for a single Action Scheduler action with it - checking twice schedules nothing extra, the schedule is one `wp_get_schedules()` knows, and the Cron Control concurrency filter keeps other plugins' entries. Assert the runner from `is_available()` rather than assuming one, so the case holds on both. - `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. @@ -179,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`, `integration/vip-search`, `integration/vip-remote-request`, `integration/vip-cron`, `integration/vip-webhook` | 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/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index c57f2f8..4310eb7 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -16,8 +16,8 @@ * 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, TTL - * or schedule outside the values VIP accepts + * 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, @@ -1007,43 +1007,6 @@ describe('integration/vip-search', () => { }); }); -describe('integration/vip-cron', () => { - it('renders into the Cron layout, reuses the wp/cron anchor and asks for wp-primitives', async () => { - const result = await registry.execute( - 'integration/vip-cron', - { name: 'sync-feed' }, - { dryRun: true, cwd: makeIncProject() } - ); - expect(result.scaffold.lens).toEqual(['vip-readiness', 'performance']); - expect(result.engine.inputs).toMatchObject({ - namespace: 'Acme\\Blog\\Cron', - hook_prefix: 'acme_blog', - hook_name: 'sync_feed', - schedule: 'hourly', - }); - expect(result.engine.wrote).toEqual(['inc/Cron/SyncFeed.php']); - expect(result.ai.wiring[0].anchor).toBe('// scaffold:wp/cron:classes'); - expect(result.developer.install.composer).toEqual({ - 'rtcamp/wp-primitives': '^2.0', - }); - }); - - it('rejects a schedule WP-Cron does not register', async () => { - const err = await rejection('integration/vip-cron', { - name: 'sync-feed', - schedule: 'every_minute', - }); - expect(err.code).toBe('EINVALIDINPUT'); - expect(err.invalid).toEqual([ - { - key: 'schedule', - value: 'every_minute', - allowed: ['hourly', 'twicedaily', 'daily', 'weekly'], - }, - ]); - }); -}); - describe('integration/vip-webhook', () => { it('renders a Rest controller with a prefixed hook and secret name', async () => { const result = await registry.execute(