Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions node-packages/wp-tooling/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- `lens` manifest key — an array of the lens skills that should check a scaffold's output once its tests are green, most relevant first, from a closed list (`accessibility`, `i18n`, `performance`, `security`, `seo`, `vip-readiness`). `validate` rejects a bare string, an empty array, duplicates, and an MCP ability name such as `runtime-health`, which is something a lens reads rather than a lens. `execute()` reports it as `scaffold.lens` and `list --json` as `lens`, both `null` when undeclared, and `null` in `list` for remote scaffolds whose manifests are not fetched. Additive: `result.scaffold` gains one field.
- New `integration` category for opinionated platform integrations, and the first scaffolds to use the reserved `"wizard_step": "integrations"`. Each builds on a `wp` kind, reuses its wiring anchor, and degrades to standard behaviour off the platform.
- `integration/vip-search` scaffold — a `Registrable` that sends heavy meta and taxonomy main queries on one post type to VIP Search through `ep_integrate`, which works on any ElasticPress backend too. Adds `META_KEYS` to VIP Search's post meta allow list (it indexes none by default), preserving whichever list or map format the allow list arrived in, since VIP discards entries that do not match it. Fires a prefixed action whenever a query that asked for the index falls back to MySQL — the silent outcome when query integration is not enabled for the environment, under VIP search rate limiting, or on an index error. Changes only the main query from `pre_get_posts`, resolving an archive's empty `post_type` the way `WP_Query` does, so category, tag and custom taxonomy archives on the post type are offloaded; secondary queries opt in where they are built. No-ops without `EP_VERSION`.
- `integration/vip-remote-request` scaffold — a service that fetches remote data over GET without an uncached request on the page path. On VIP it delegates to `wpcom_vip_file_get_contents()` (cached, stale-on-failure, backs off a failing host); `vip_safe_wp_remote_get()` is deliberately not used because it caches nothing. Elsewhere it caches successful responses only in the object cache via `rtCamp\WPPrimitives\Utils\Cache` and fetches with `wp_safe_remote_get()`. Internal hosts are refused on both paths, and only when a request is actually sent, so a cached or stale copy never waits on a DNS lookup. `timeout` and `cache_ttl` are `enum`-constrained to what `RemoteRequestTimeout` (at most 3s) and `LowExpiryCacheTime` (at least 300s) accept.
- `integration/vip-webhook` scaffold — a REST controller that verifies an HMAC-SHA256 signature over the raw body in constant time, refuses every delivery until a shared secret is set (a VIP environment variable, or a constant of the same prefixed name elsewhere — never written by the scaffold), caps the body at 64 KB, accepts a JSON object or an array (as batch senders post), acknowledges a repeated delivery to the same webhook with 200 without queueing it twice for a day (atomically in a persistent object cache, in a transient otherwise, so retries are caught without one), and queues the payload on Action Scheduler or WP-Cron before answering 202 instead of doing the work in the request. The REST module it wires into is the `module_path` input, so a project whose module is `REST.php` is not sent to a `Rest.php` that does not exist.
Comment on lines +13 to +15

## [2.0.0] - 2026-10-01

### Added
Expand Down
4 changes: 3 additions & 1 deletion node-packages/wp-tooling/docs/ai-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [...] },
Expand All @@ -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:<dot.path>` including `autoload.psr-4` and `autoload-dev.psr-4`, `package.json:<dot.path>`, `plugin-header:<header-name>` from the plugin or theme entry header, `config:<key>` 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).
Expand Down Expand Up @@ -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.

Expand Down
26 changes: 26 additions & 0 deletions node-packages/wp-tooling/docs/authoring-scaffolds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` in wp-dev-tools) that decides for itself which MCP abilities to read, so an ability name such as `runtime-health` is rejected — name the lens that reads it (`performance`) instead. A typo fails `validate` rather than silently skipping the check.
- **A recommendation, not a dependency.** The list includes lenses a given project may not have installed; the orchestrating skill runs the ones it finds. That is also why the enum can name a lens before it ships, without a wp-tooling release for each new one.
- **Optional.** Omit it and `execute()` and `list --json` report `lens: null`, leaving the choice to the orchestrating skill.

Order by what the generated code most needs checked. `integration/vip-webhook` leads with `security` because signature verification is its riskiest code.

To add a lens, extend `ALLOWED_LENSES` in `src/scaffolds/schema.js` — `validate.js` and the schema-parity test read it from there.

---

## Features (`feature{}`) — toggleable scaffolds

An optional `feature` block turns a scaffold into something `wp-tooling features` can switch on and
Expand Down Expand Up @@ -313,6 +332,8 @@ Use nesting when a scaffold has multiple variants of the same concept (PHPCS sta

`wp` holds the framework-shaped kinds (a CPT, a REST controller, a CLI command). `wp-api` holds scaffolds that customise a **modern WordPress core API** — code whose shape is dictated by core's own hooks and which must be guarded against the WordPress version that introduced them (`wp-api/speculation`, Speculation Rules, WP 6.8; `wp-api/block-bindings` and `wp-api/script-module`, both WP 6.5). Those pair with `"wizard_step": "wp-apis"`.

`integration` holds opinionated integrations with a specific platform or service, where the value is encoding that platform's rules rather than a WordPress primitive (`integration/vip-search`, `integration/vip-remote-request`, `integration/vip-webhook`). Each builds on a `wp` kind and reuses its wiring anchor — `vip-search` wires where `wp/registrable` does, `vip-webhook` where `wp/rest` does — and must degrade to working, standard behaviour off the platform instead of fataling. They pair with `"wizard_step": "integrations"`. Prefer a scaffold here only when the output is code the project owns and keeps; configuring an existing plugin, or an ongoing sync, is skill-shaped rather than scaffold-shaped.

A block stays in `wp` even when its behaviour comes from a modern core API: `wp/block-interactive` uses the Interactivity API, but what it generates is a block directory, so it sits beside `wp/block-dynamic` and shares its wiring anchor rather than opening a `block` category.

---
Expand Down Expand Up @@ -425,6 +446,11 @@ Look at these existing scaffolds when authoring a new one:
| Block with `block.json` + framework class | `wp/block-dynamic` |
| Block with `render.php` + a `viewScriptModule` store | `wp/block-interactive` |
| Workflow / YAML scaffold with secrets | `ci/cd-wporg` |
| Platform-specific code that no-ops or falls back off the platform | `integration/vip-search`, `integration/vip-remote-request` |
| Service class with no wiring, instantiated by its callers | `integration/vip-remote-request` |
| Input constrained to values a coding standard accepts (`enum`) | `integration/vip-remote-request` (`timeout`, `cache_ttl`) |
| Hook and constant names prefixed from the project's text domain | `integration/vip-*` (`hook_prefix` / `secret_prefix` derived via `input:text_domain`) |
| Extending a `wp` kind and reusing its wiring anchor | `integration/vip-search` (`wp/registrable`), `integration/vip-webhook` (`wp/rest`) |
| Scaffold hosted in another repo (sources + index) | `scaffolds/sources.json` + `tests/fixtures/scaffolds-sources/sources.json` |

Copy the closest match, rename, adjust. Most scaffolds are 20-50 lines of JSON plus one template file plus a test stub.
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
/**
* {{class}}: cached, VIP-safe remote GET requests.
*
* Scaffolded by @rtcamp/wp-tooling.
*
* @link https://github.com/rtCamp/wp-primitives
* @link https://docs.wpvip.com/technical-references/code-quality-and-best-practices/retrieving-remote-data/
*
* @package {{namespace}}
*/

declare( strict_types = 1 );

namespace {{namespace}};

use rtCamp\WPPrimitives\Utils\Cache;

/**
* Fetches remote data without putting an uncached request on the page path.
*
* On WordPress VIP this delegates to wpcom_vip_file_get_contents(), which
* caches the body, serves a stale copy when the remote end fails, and stops
* calling a host that keeps failing. vip_safe_wp_remote_get() is deliberately
* not used: it backs off the same way but caches nothing, so every page view
* would still pay for the request. The helper fetches with wp_remote_get(),
* so the request asks core to refuse internal hosts, as wp_safe_remote_get()
* does. Core checks the URL only when a request is sent, so a cached or stale
* copy is still served without a DNS lookup.
*
* Everywhere else the body is kept in the object cache and fetched with
* wp_safe_remote_get(), which refuses internal hosts. Only successful
* responses are cached there, so a single failure is not served for the
* whole TTL.
*/
final class {{class}} {

/**
* Request timeout in seconds. VIP accepts no more than 3.
*/
public const TIMEOUT = {{timeout}};

/**
* Seconds a successful response is cached. VIP accepts no less than 300.
*/
public const CACHE_TTL = {{cache_ttl}};

/**
* Object cache group for responses fetched outside VIP.
*/
public const CACHE_GROUP = '{{cache_group}}';

/**
* Object cache, namespaced to this plugin or theme.
*
* @var Cache
*/
private Cache $cache;

/**
* Constructor.
*
* @param \rtCamp\WPPrimitives\Utils\Cache|null $cache Cache to store responses in. Defaults to one namespaced by text domain.
*/
public function __construct( ?Cache $cache = null ) {
$this->cache = $cache ?? new Cache( '{{text_domain}}' );
}

/**
* Fetch a URL over GET and return its body.
*
* @param string $url Absolute URL to fetch.
* @param array<string, mixed> $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 );
Comment on lines +84 to +86

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;
}
}
Loading