From 1d34ec37eaa63082bdafc98ce714b98580094dcb Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 1 Oct 2026 16:01:46 +0530 Subject: [PATCH 01/15] fix(scaffolds): harden generation and tooling --- node-packages/wp-tooling/CHANGELOG.md | 40 ++- .../wp-tooling/docs/ai-orchestration.md | 16 +- .../wp-tooling/docs/authoring-scaffolds.md | 13 +- node-packages/wp-tooling/docs/examples.md | 12 +- .../wp-tooling/docs/wp-framework-contract.md | 4 +- .../scaffolds/lint/phpcs/core/scaffold.json | 53 +++- .../core/templates/phpcs.xml.dist.mustache | 83 +++++- .../scaffolds/lint/phpcs/full/scaffold.json | 54 +++- .../full/templates/phpcs.xml.dist.mustache | 97 +++++-- .../scaffolds/lint/phpcs/vip/scaffold.json | 10 +- .../claude-skills/templates/scaffold-SKILL.md | 5 +- .../templates/scaffold-evals.json | 16 ++ .../claude-skills/templates/setup-SKILL.md | 30 +- .../setup/perf/templates/server-profile.php | 10 +- .../scaffolds/setup/phpunit/scaffold.json | 6 + .../phpunit/templates/bootstrap.php.mustache | 7 +- .../wp-api/block-bindings/scaffold.json | 7 + .../templates/block-bindings.php.mustache | 2 +- .../wp-api/script-module/scaffold.json | 14 +- .../templates/script-module.php.mustache | 34 ++- .../wp-api/speculation/scaffold.json | 1 + .../scaffolds/wp/admin-page/scaffold.json | 13 + .../templates/admin-page.php.mustache | 18 +- .../wp/admin-page/templates/test.php.mustache | 12 +- .../scaffolds/wp/block-dynamic/scaffold.json | 25 ++ .../templates/block-class.php.mustache | 27 +- .../templates/block.json.mustache | 4 +- .../block-dynamic/templates/edit.js.mustache | 4 +- .../block-dynamic/templates/index.js.mustache | 4 +- .../block-dynamic/templates/test.php.mustache | 12 +- .../wp/block-interactive/scaffold.json | 19 ++ .../templates/block-registrar.php.mustache | 2 +- .../templates/block.json.mustache | 4 +- .../templates/edit.js.mustache | 4 +- .../templates/index.js.mustache | 4 +- .../templates/test.php.mustache | 4 +- .../templates/view.js.mustache | 6 +- .../wp-tooling/scaffolds/wp/cli/scaffold.json | 1 + .../wp/cli/templates/command.php.mustache | 10 +- .../wp/cli/templates/test.php.mustache | 17 +- .../wp-tooling/scaffolds/wp/cpt/scaffold.json | 13 + .../wp/cpt/templates/post-type.php.mustache | 14 +- .../wp/cpt/templates/test.php.mustache | 15 +- .../scaffolds/wp/cron/scaffold.json | 1 + .../wp/cron/templates/cron.php.mustache | 6 +- .../wp/cron/templates/test.php.mustache | 12 +- .../scaffolds/wp/module/scaffold.json | 1 + .../wp/module/templates/module.php.mustache | 4 +- .../wp/module/templates/test.php.mustache | 50 +++- .../scaffolds/wp/registrable/scaffold.json | 1 + .../templates/registrable.php.mustache | 4 +- .../registrable/templates/test.php.mustache | 16 +- .../scaffolds/wp/rest/scaffold.json | 1 + .../wp/rest/templates/controller.php.mustache | 6 +- .../wp/rest/templates/test.php.mustache | 19 +- .../scaffolds/wp/settings-page/scaffold.json | 13 + .../templates/settings-page.php.mustache | 14 +- .../settings-page/templates/test.php.mustache | 22 +- .../scaffolds/wp/shortcode/scaffold.json | 1 + .../templates/shortcode.php.mustache | 6 +- .../wp/shortcode/templates/test.php.mustache | 20 +- .../scaffolds/wp/taxonomy/scaffold.json | 13 + .../taxonomy/templates/taxonomy.php.mustache | 14 +- .../wp/taxonomy/templates/test.php.mustache | 15 +- .../scaffolds/wp/user-role/scaffold.json | 7 + .../wp/user-role/templates/test.php.mustache | 15 +- .../templates/user-role.php.mustache | 10 +- .../wp-tooling/skills/scaffold/SKILL.md | 5 +- .../skills/scaffold/evals/evals.json | 16 ++ .../wp-tooling/skills/setup/SKILL.md | 30 +- .../src/cli/commands/release-zip.js | 5 +- .../wp-tooling/src/release/context.js | 85 ++++-- node-packages/wp-tooling/src/release/index.js | 3 +- node-packages/wp-tooling/src/release/zip.js | 74 ++++- .../wp-tooling/src/scaffolds/php-imports.js | 156 ++++++++++ .../wp-tooling/src/scaffolds/registry.js | 161 +++++++++-- .../wp-tooling/src/scaffolds/render.js | 23 ++ .../wp-tooling/src/scaffolds/schema.js | 9 + .../wp-tooling/src/scaffolds/validate.js | 119 +++++--- .../wp-tooling/tests/release/bump.test.js | 25 +- .../wp-tooling/tests/release/zip.test.js | 48 ++++ .../tests/scaffolds/bundled-manifests.test.js | 213 +++++++++++++- .../tests/scaffolds/discover-sources.test.js | 269 ++++++++++++++++++ .../tests/scaffolds/php-imports.test.js | 199 +++++++++++++ .../wp-tooling/tests/scaffolds/render.test.js | 22 ++ .../tests/scaffolds/schema-parity.test.js | 147 ++++++++++ 86 files changed, 2299 insertions(+), 327 deletions(-) create mode 100644 node-packages/wp-tooling/src/scaffolds/php-imports.js create mode 100644 node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js create mode 100644 node-packages/wp-tooling/tests/scaffolds/php-imports.test.js create mode 100644 node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 71389de..846d44e 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -21,14 +21,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `wp/block-interactive` scaffold — generates a block driven by core's Interactivity API (core since WP 6.5): a `block.json` declaring `supports.interactivity` plus `viewScriptModule`, a `render.php` emitting the `data-wp-*` directives, a `view.js` store, the editor entry points, and a `Registrable` that registers the block from its build output. Deliberately not an `AbstractBlock` subclass — that abstract forces a `render_callback`, which would override `block.json`'s `render`. Registration no-ops before WP 6.5 and before a build has run. Shares the Blocks module and the `// scaffold:wp/block-dynamic:classes` anchor with `wp/block-dynamic`. - `wp-api/block-bindings` scaffold — generates a `Registrable` that registers a Block Bindings source (core since WP 6.5) through `register_block_bindings_source()` on `init`, with core's exact `get_value_callback` signature, a `USES_CONTEXT` declaration so the callback can read the bound block's context, and a `function_exists()` guard for WP < 6.5. - `wp-api/script-module` scaffold — generates a Script Module (core since WP 6.5): an ES module plus a `Registrable` that registers it under an `@vendor/name` id, reads dependencies and a cache-busting version from the build's generated `.asset.php`, normalises string dependencies to core's `[ 'id' => ... ]` form, and enqueues it on a hook constrained by `enum`. Stays on the WP 6.5 signature — `wp_register_script_module()`'s `$args` parameter arrived in 6.9. +- `lint/phpcs/full` on `rtCampWP` and `lint/phpcs/core` on `rtCampWP-Basic`, both from `rtcamp/wp-phpcs`, the standards the v2 plugin and theme skeletons use. Both variants: + - fill `testVersion` from the `Requires PHP` header (PHPCompatibility aborts on the first line of every file without it), `minimum_wp_version` from `Requires at least`, the text domain from `Text Domain`, and the prefixes from the text domain and the PSR-4 namespace; + - accept PSR-4 class file names, and exempt block `render.php` files from the global-variable prefix and text-domain-mismatch checks, since core includes them inside a function and they translate with `block.json`'s text domain; + - lint `tests/`, exempting it from the prefix, VIP file-operation and remote-data, `ini_set` and global-override rules by sniff name. An exclude-pattern on a re-referenced standard such as `WordPress-Docs` has no effect, because its sniffs are already loaded; + - declare `rtcamp/wp-phpcs` as the only dev dependency, with two `composer.json` wiring entries without which `composer require` fails: the `rtCamp/wp-phpcs` VCS repository, since the package is not on Packagist, and `allow-plugins` for the Composer installer that registers the standards. + + Every PHP file the bundled `wp/*`, `wp-api/*` and `setup/phpunit` scaffolds render (classes, tests and the PHPUnit bootstrap), and the five `utility/*` accessor snippets, passes both. +- `automattic/vipwpcs` as `lint/phpcs/vip`'s dev dependency, with `allow-plugins` wiring for `dealerdirect/phpcodesniffer-composer-installer`, which WPCS and VIPCS depend on: without it a non-interactive `composer require` exits 1 and no standard is registered. The `setup` skill's install command and `docs/examples.md` use the same package. +- VIP project detection in the `setup` skill, matching `WordPressVIPMinimum`, `WordPress-VIP-Go`, `automattic/vipwpcs`, `VIP_GO_APP_ENVIRONMENT` or `VIP_GO_ENV` across `*.php`, `*.xml`, `*.dist` (including `phpcs.xml.dist`), `*.json` and `*.yml`. +- `tests_namespace` input for `setup/phpunit`, which names `tests/bootstrap.php`'s `@package` after the project's PSR-4 test namespace so the bootstrap passes `WordPress-Docs`. ### Fixed +- Human-readable scaffold labels now escape apostrophes and backslashes in PHP and JavaScript string literals, and quotes in block JSON metadata. Titles such as `Editor's "guide"` generate valid code while preserving the original display text. +- Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly. +- Generated block JavaScript follows the current shared ESLint formatting rules. +- PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics. - `discover_from: composer.json:autoload.psr-4` now resolves **path** inputs (`base_path`, `*_path`, `*_dir`) from the map entry's directory instead of skipping them, grafted the same way namespaces already were — with a root of `Acme\Blog\` → `inc/`, a `base_path` default of `includes/Services` resolves to `inc/Services`. Previously the namespace was grafted but the directory kept the manifest default, so any project not laid out under `includes/` got a correctly-namespaced class written outside its autoload root, where it never loaded. Affects the 12 bundled scaffolds that declare PSR-4 discovery on `base_path`. Projects already using `includes/`, projects without a `composer.json`, and callers that pass `--base_path` explicitly are unaffected. - Feature finalization rolls back successful hooks when detection or state persistence fails, and defers next-step messages until persistence succeeds. Setup/reinit identity and version writes share rollback protection. Rename batches reuse one plan, support chains and swaps, and detect case-insensitive destination collisions. Cleanup targets and explicit example markers reject malformed values before mutation. - Init validates shared setup/manage selections before mutation, confirms setup choices before applying identity, and uses current persisted tokens for reinitialization. Version-only edits now update files and identity; reinit preserves one-shot example choices and refuses corrupt identity. - Feature hooks are awaited inside their rollback journal. Cancellation/no-op manage calls leave persisted state unchanged; failed transitions stop, retain shared dependency ownership, and report rollback failures. - Init rejects rename collisions, unsafe configured paths and symlink escapes, propagates filesystem and external-step failures, and reports partial setup instead of unconditional success. Identity edits roll back file changes on failure and preserve unrelated metadata. Fully rolled-back feature transitions leave saved state untouched, and dependency/script values must be strings. Git-step failures report that project setup completed; failures during project setup still require inspecting partial changes before retrying. +- Scaffolded PHP now passes `rtCampWP` clean. Rendering all 12 `wp/*` scaffolds into a skeleton consumer produced **105 PHPCS errors** before this release and none after (`use`-ordering, the one thing no template can settle, is now sorted by the engine after rendering — see the entry below). The causes: + - 24 templates emitted `declare(strict_types=1);`, whose spacing `SlevomatCodingStandard.TypeHints.DeclareStrictTypes` rejects — one error in every file the older scaffolds wrote. `setup/phpunit`'s `tests/bootstrap.php` had no `declare` line at all. + - 35 bare `@return` annotations restated a native return type without adding a description or a generic shape, which `ReturnTypeHint.UselessAnnotation` rejects. Removed, matching how the hand-written skeleton classes document typed methods. + - 41 missing docblocks in the older test stubs: no `@package` in the file comment (12) and no doc comment on a test method (29). + - 5 `use` blocks ordered so `AlphabeticallySortedUses` rejected them under its case-sensitive setting, in the `wp/block-dynamic`, `wp/cli` and `wp/rest` classes and the `wp/cli` and `wp/registrable` test stubs. +- Rendered PHP has its `use` block sorted by the engine, so generated code satisfies `AlphabeticallySortedUses` whatever the consuming project is called. The correct order depends on where the project's own namespace sorts against `PHPUnit\` and `WP_*`, which a Mustache template cannot know: the same test stub needs one order under `Inc\…` and the opposite under `rtCamp\…`. Rendering into a theme namespaced `rtCamp\Theme\Elementary` used to leave an error in **every** generated test file. The sort reproduces the sniff exactly — segment by segment, byte-wise, case sensitive — and is deliberately narrow: a group import, an alias, a `use function`/`use const`, a multi-line statement or a comment inside the block all leave the file untouched rather than risk a bad edit, and indented trait imports are never touched. +- `discover_from: plugin-header:` is implemented. Seven `wp/*` scaffolds have always declared `plugin-header:text-domain`, but nothing resolved it — the prefix fell through to `undefined` and every scaffold silently emitted the manifest default `my-plugin`, which is a PHPCS `Mismatched text domain` error in any project not actually called that (twelve of them in a single skeleton run). The engine now reads the entry header, preferring a root `*.php` with `Plugin Name:` and falling back to a theme's `style.css` with `Theme Name:`, scanning only the header comment. +- `discover_from: composer.json:autoload-dev.psr-4` is supported, and every scaffold that writes a test now uses it for `tests_path`. The selector was previously unrecognised — declaring it was a silent no-op — so test stubs went to the manifest default (`tests/`) while both first-party consumers autoload *and* collect tests from `tests/php/`. The generated test was not merely unloadable, it was never collected: PHPUnit's testsuite is a directory glob, so the case silently did not exist. +- `validate` rejects a `discover_from` naming a source the engine cannot resolve, listing the ones it can. This is the guard that would have caught the two entries above at authoring time rather than in generated output. +- `release:bump`, `release:changelog` and `release:zip` work on themes. They resolved the project through a `Plugin Name:` header only, so every one of them failed outright in a theme with *no plugin entry file found*. They now fall back to a `style.css` carrying `Theme Name:`, taking the slug from the directory — which is what WordPress keys a theme on — and `release:bump` rewrites the `Version:` header there. +- `release:zip` warns when the payload is far too large to be a release, attributing the bulk to the top-level directories responsible. Building the skeleton with dev dependencies installed plans a **2.6 GB** archive, because `vendor/` is not in the default ignore list — and it should not be, since a plugin that autoloads its Composer dependencies has to ship it. Dropping it by default would quietly produce a broken plugin, so the size is reported instead, pointing at `composer install --no-dev`. +- `wp/rest` no longer generates a controller that fatals on load. `WP_REST_Controller::get_items()` and `get_items_permissions_check()` declare `$request` untyped, so the generated `WP_REST_Request $request` narrowed an inherited `mixed` — which PHP rejects outright: *"Declaration … must be compatible with …"*, taking down the whole request. Both now leave the parameter and return untyped, matching the parent; the real types live in `@param \WP_REST_Request` / `@return` PHPDoc, and `WP_REST_Response` is fully qualified so no docblock-only `use` is left for a formatter to strip. +- Generated tests no longer fire a global action hook to trigger their own registration. `wp/shortcode` called `do_action( 'init' )` and `wp/settings-page` called `do_action( 'admin_init' )`, which re-run every *other* listener on those hooks — in a real project that means duplicate block registrations and headers sent after the harness flushed them, both of which `WP_UnitTestCase` fails on. They now call `register_shortcode()` and `register_settings()` directly. `wp/rest` had the mirror-image bug — it called `register_routes()` outside `rest_api_init`, the only context where `register_rest_route()` is legal — and now goes through the hook. +- `wp/admin-page` no longer emits a comparison that is always true. The template rendered `$parent = '{{parent_slug}}'; return '' === $parent ? null : $parent;`, so leaving `parent_slug` at its default — every top-level page — produced `'' === ''` and a PHPStan `identical.alwaysTrue`. The template now emits a `get_parent_slug(): string` override only when `parent_slug` is set; a top-level page inherits the framework's `null` default, so neither variant returns a type wider than it needs (`checkTooWideReturnTypesInProtectedAndPublicMethods`, which `rtcamp/wp-phpstan` enables). +- `wp/block-dynamic` emits `protected function get_block_dir(): string`. It was `public ... : ?string`, widening the visibility `AbstractBlock` declares (the same divergence fixed above for `get_classes()`) and keeping a nullable half the body never returns. +- `wp/cpt`, `wp/taxonomy` and `wp/user-role` label accessors satisfy the framework's `@return non-empty-string`. `__()` is typed `string`, so at PHPStan level 8 — which `theme-elementary` runs — five accessors failed the inherited contract; they now fall back to the untranslated literal. +- `wp-api/script-module` builds its dependency entries key by key instead of passing the caller's array through, so the result is exactly the `array{id: string, import?: string}` shape `wp_register_script_module()` documents rather than whatever else the array carried. +- `wp/block-interactive`'s test docblock starts with a capital, which `Generic.Commenting.DocComment.ShortNotCapital` requires and consumers that lint `tests/` enforce. +- `wp/module` now emits `protected function get_classes()`, matching the `abstract protected` the framework's `AbstractModule` declares and the visibility every hand-written module uses; scaffolded modules were the only ones widening it to `public`. Its generated test reaches the method through reflection, since the module is `final`. +- `wp/module`'s generated test no longer fails a consumer suite on the run that creates the module. `test_each_listed_class_is_loadable` asserted only inside a `foreach` over the class list, which is empty in a freshly scaffolded module — no assertion recorded, so PHPUnit marked the case risky and consumers run with `failOnRisky`. It now compares the whole list against the loadable subset. +- The scaffold skill now states outright that it must not wire a class into a spot a later `wp-tooling init` deletes. rtCamp skeletons wrap optional capabilities in `// wp:example:` … `:end` regions and `init` removes a dropped region body and all, but the insertion rule was "after the last sampled occurrence" — which in `Main::CLASSES` is the last line *inside* such a region. `SKILL.md` and `docs/ai-orchestration.md` now refuse that placement at every fall-through level, anchors included. Hardening rather than a repair: on the eval fixtures the previous wording already landed the snippet outside the region, so this makes a safe behaviour a stated rule instead of an inference. +- `validate.js` derives its manifest key allow-lists from `schema.js` instead of restating them. The two carried independent copies of the same seven lists, so a key could be accepted by the schema and rejected by the validator that actually runs; adding `enum` needed both edits and nothing would have caught doing only one. +- A `wiring` entry whose `target_file` still resolves outside the project after normalisation is dropped with a warning rather than handed to the AI to edit. `path.posix.normalize` collapses interior `..` but keeps a leading one, so a single-segment path input reached above the project root. +- `docs/wp-framework-contract.md` documented `get_classes()` as `public` and described the wiring snippet as going *above* the module's marker comment, contradicting both the skill and `docs/ai-orchestration.md`. ## [1.0.0] - 2026-07-30 @@ -71,4 +109,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `eslint.config.js` — repo-local flat config extending `@rtcamp/eslint-config`, with Node globals and `n/no-restricted-require` banning runtime deps > The shareable ESLint, Stylelint and Tailwind configs that previously lived here moved to the -> standalone `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` packages. \ No newline at end of file +> standalone `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` packages. diff --git a/node-packages/wp-tooling/docs/ai-orchestration.md b/node-packages/wp-tooling/docs/ai-orchestration.md index f6fc9b1..ffefaea 100644 --- a/node-packages/wp-tooling/docs/ai-orchestration.md +++ b/node-packages/wp-tooling/docs/ai-orchestration.md @@ -60,7 +60,7 @@ The engine commits to the following on a successful run. Skills can rely on thes - `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`. - `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:`, `package.json:`, `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:*` and `plugin-header:*` sources are **not** engine-resolved and remain the skill's responsibility (§6). +- 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). - A scaffold may carry an optional `feature` block, marking it a **toggleable feature** (the bundled example is `setup/tailwind`). This block is **invisible to `add`/`execute`**: it never appears in the result, and `add` behaves identically whether or not it is present. Turning features on and off is a separate surface (§13). ## 4. Skill responsibilities @@ -265,6 +265,20 @@ Fall through these in order: 3. **No anchor and no clear pattern neighbourhood.** The bootstrap method exists (from §6) but there are no prior examples of this scaffold type. Insert at a sensible position in the bootstrap method (typically just before the closing brace), and explicitly tell the developer this is a best-effort placement. 4. **No bootstrap method either.** Skip wiring, surface the snippet as a manual instruction in the report, let the developer place it. +Levels 2 and 3 place the snippet relative to code that may not survive. rtCamp +skeletons wrap each optional capability in `// wp:example:` … `// +wp:example::end` markers, and `wp-tooling init` deletes a marked region +**including its body** when the developer drops that capability +(`src/init/examples.js`). The last sampled occurrence of a pattern is very often +the last line inside such a region, so appending after it puts the newly wired +class inside a block that later disappears — silently, and long after the run +that placed it. + +**Never place the snippet between a `// wp:example:` opener and its +`:end`.** Insert after the last occurrence that sits outside every region +instead, and say which line you chose and why. This holds at level 1 too: an +anchor can itself sit inside a region. + At every level the skill **shows the developer the snippet, the target file, the chosen line range, and the reason for the choice** before applying. The developer can apply, redirect to a different location, edit the snippet, or skip. ### Anchor restoration (optional, never automatic) diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index 698384e..25d4fbe 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -139,8 +139,14 @@ exactly as if `discover_from` were not set). - `input:` — derive from another resolved input (e.g. `class` from `name`, with a `pascal-case` transform). - `composer.json:` / `package.json:` — a string value at a dotted path. The special selector `autoload.psr-4` (or `autoload.psr-0`) yields the **root namespace** for ordinary inputs (first map key, trailing `\` stripped) and the **root directory** for path inputs (that same entry's value; the first element if it is a list). Either way the discovered root replaces only the *first segment* of the input's `default`, keeping the scaffold's sub-namespace or sub-directory: with a map of `Acme\Blog\` → `inc/`, a `namespace` default of `Inc\Cli` yields `Acme\Blog\Cli` and a `base_path` default of `includes/Cli` yields `inc/Cli`. Both come from the same map entry, so a class is never namespaced into the autoload root while being written outside it. A path input whose `default` has no sub-directory resolves to the root directory itself, and a PSR-4 target of `./` leaves just the sub-directory. +- `composer.json:autoload-dev.psr-4` — the same graft against the **dev** autoload map, which is where a project declares its test tree. Use it for `tests_path`: a dev map of `Acme\Blog\Tests\` → `tests/php/` turns a default of `tests/Cli` into `tests/php/Cli`, so the generated test lands where the project's PHPUnit testsuite actually looks. Without it a test is written to the manifest default and is silently never collected. +- `plugin-header:` — a value from the project's WordPress entry header, named lowercased with spaces hyphenated: `plugin-header:text-domain` reads `Text Domain:`. The engine looks for a root-level `*.php` carrying `Plugin Name:` and falls back to a theme's `style.css` carrying `Theme Name:`, so this resolves for plugins and themes alike. Only the header comment is scanned, so a `Foo: bar` in the code below is never mistaken for a header. - `config:` — a string value from the project's `.wp-tooling.json` (e.g. `config:textDomain`). +`validate` rejects a `discover_from` whose prefix is not one of the above. An unrecognised +source used to resolve to nothing and fall through to the `default` in silence, which is how +seven scaffolds shipped for months declaring a text-domain lookup that never ran. + Example — auto-fill the namespace from the consuming project's composer.json, falling back to a sensible default: ```json @@ -163,6 +169,9 @@ Available via `transform`: - `snake-case`: `qm-export` → `qm_export` - `upper-snake-case`: `wporg-username` → `WPORG_USERNAME` - `json-escape`: `Acme\Blog` → `Acme\\Blog` (embed a PHP namespace in a JSON snippet) +- `php-escape`: escapes backslashes and apostrophes for a PHP string enclosed in single quotes. +- `js-string`: emits a complete JavaScript string literal, escaping control characters and choosing the quote style used by the formatter. Templates do not add surrounding quotes. +- `php-project-root`: derives a PHP expression from a project-relative directory (`inc/Blocks` → `dirname( __DIR__, 2 )`, `.` → `__DIR__`). Block and script module scaffolds use it to find their project root with custom source layouts. - `shell-escape`: `my plugin` → `'my plugin'` (POSIX-quote one shell argument; safe values like `wp-content/plugins/x` stay bare) Transforms are applied after the value is resolved. Add new transforms in `src/scaffolds/render.js` (`TRANSFORMS` map). @@ -207,8 +216,8 @@ The engine never edits existing files. When a scaffold needs to register itself Each entry: -- `target_file`: path to the file. Placeholders allowed. -- `anchor`: grep-able string that hints at insertion point (e.g. `// scaffold:cli-commands`). Anchors are useful but not load-bearing; the AI falls back to pattern sampling. +- `target_file`: path to the file, relative to the project root. Placeholders allowed. The engine normalises the rendered path, so `{{base_path}}/../Modules/Cli.php` reaches the AI as `includes/Modules/Cli.php` — write the `..` form rather than duplicating a path input. An entry whose path still resolves outside the project after normalising is dropped with a warning. +- `anchor`: grep-able string that hints at insertion point (e.g. `// scaffold:cli-commands`). Anchors are useful but not load-bearing; the AI falls back to pattern sampling. Either way it refuses to insert inside a `// wp:example:` … `:end` region, since `wp-tooling init` deletes those body and all. - `snippet_template`: the snippet to insert. Mustache placeholders are rendered before the snippet is emitted. - `description`: explains intent. The AI uses this when asking the developer for consent. diff --git a/node-packages/wp-tooling/docs/examples.md b/node-packages/wp-tooling/docs/examples.md index f6c39ac..81467a6 100644 --- a/node-packages/wp-tooling/docs/examples.md +++ b/node-packages/wp-tooling/docs/examples.md @@ -30,7 +30,7 @@ Each example shows: what the developer typed, the AI's clarifying questions and Phase A — Project setup: 1. setup/editorconfig → .editorconfig 2. setup/psr4 → composer.json wiring (Acme\InventoryManager → includes/) - 3. lint/phpcs/full → phpcs.xml.dist (rtCamp full standard) + 3. lint/phpcs/full → phpcs.xml.dist (rtCampWP) + composer.json wiring 4. lint/phpstan → phpstan.neon.dist (level 5) 5. lint/eslint → eslint.config.js 6. setup/phpunit → phpunit.xml.dist, tests/bootstrap.php @@ -70,6 +70,12 @@ npx wp-tooling add wp/cli --non-interactive --json --cwd . --name=im **Developer:** yes. +> For `lint/phpcs/full`, `rtcamp/wp-phpcs` is not on Packagist, so I would add its GitHub repository to `repositories` and allow `dealerdirect/phpcodesniffer-composer-installer` under `config.allow-plugins` in `composer.json`. +> +> Apply? [yes / skip] + +**Developer:** yes. + (AI applies wirings for `Plugin.php` for the CPT, taxonomy, CLI registrations one at a time.) **Final report:** @@ -90,7 +96,7 @@ Developer actions: composer dump-autoload --optimize composer require rtcamp/wp-framework:^1.0 - composer require --dev squizlabs/php_codesniffer:^3.7 wp-coding-standards/wpcs:^3.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 + composer require --dev rtcamp/wp-phpcs:^1.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^0.1.0 Scripts to add to composer.json: @@ -465,7 +471,7 @@ Files written (10), wiring applied (1 in Plugin.php for the CLI command). Outstanding developer actions: composer dump-autoload --optimize - composer require --dev automattic/vip-coding-standards:^3.0 wp-coding-standards/wpcs:^3.0 squizlabs/php_codesniffer:^3.7 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 + composer require --dev automattic/vipwpcs:^3.0 wp-coding-standards/wpcs:^3.0 squizlabs/php_codesniffer:^3.7 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 ``` --- diff --git a/node-packages/wp-tooling/docs/wp-framework-contract.md b/node-packages/wp-tooling/docs/wp-framework-contract.md index 00a5e69..b6c18f5 100644 --- a/node-packages/wp-tooling/docs/wp-framework-contract.md +++ b/node-packages/wp-tooling/docs/wp-framework-contract.md @@ -148,7 +148,7 @@ Wiring: `register_hooks()` attaches `register_routes()` to `rest_api_init`. ### `AbstractModule` (extended by `scaffolds/wp/module`) -Required: `public get_classes(): array` returning a list of class strings. +Required: `protected get_classes(): array` returning a list of class strings. The base declares it `abstract protected`; scaffolded modules match that visibility, so tests reach it through reflection. Wiring: the framework's `Loader` instantiates each listed class and calls `register_hooks()` if it implements `Registrable`. @@ -226,7 +226,7 @@ instance (laps; the flag registry), so they must be shared rather than construct ## Anchor convention -Every scaffold that produces a class registers via the consumer's `AbstractModule` subclass. The module file contains a marker comment inside `get_classes()`; the scaffold's `wiring` block inserts the new class string immediately above the marker. +Every scaffold that produces a class registers via the consumer's `AbstractModule` subclass. The module file contains a marker comment inside `get_classes()`; the scaffold's `wiring` block inserts the new class string immediately after the marker. Marker pattern: `// scaffold::classes`. Examples: diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json index 7f7fda7..f914b8d 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/scaffold.json @@ -1,18 +1,63 @@ { "slug": "core", "category": "lint/phpcs", - "name": "PHPCS - WordPress", - "description": "PHP_CodeSniffer with the WordPress combined standard (Core + Extra + Docs) from wp-coding-standards/wpcs.", + "name": "PHPCS - rtCampWP-Basic", + "description": "PHP_CodeSniffer with rtCampWP-Basic from rtcamp/wp-phpcs: WordPress-Extra, Docs, VIP-Go and PHP compatibility, without the strict-typing layer of rtCampWP. For adopting standards on existing code.", "source": "template", + "inputs": [ + { + "key": "text_domain", + "description": "Text domain every translatable string must use. Read from the plugin or theme header.", + "discover_from": "plugin-header:text-domain", + "default": "my-plugin" + }, + { + "key": "prefix", + "description": "Prefix required on global functions, hooks, constants and variables. Derived from text_domain.", + "discover_from": "input:text_domain", + "transform": "snake-case" + }, + { + "key": "namespace", + "description": "Root PHP namespace, which PHPCS also accepts as a prefix. Read from composer.json PSR-4 autoload; left out when the project has none.", + "discover_from": "composer.json:autoload.psr-4", + "default": "" + }, + { + "key": "php_version", + "description": "Lowest PHP version the project supports, e.g. 8.2. Read from the Requires PHP header.", + "discover_from": "plugin-header:requires-php", + "default": "8.2" + }, + { + "key": "wp_version", + "description": "Lowest WordPress version the project supports, e.g. 6.5. Read from the Requires at least header.", + "discover_from": "plugin-header:requires-at-least", + "default": "6.5" + } + ], "files": [ { "src": "templates/phpcs.xml.dist.mustache", "dest": "phpcs.xml.dist" } ], + "wiring": [ + { + "target_file": "composer.json", + "anchor": "\"repositories\"", + "snippet_template": "\"repositories\": [\n {\n \"type\": \"vcs\",\n \"url\": \"https://github.com/rtCamp/wp-phpcs.git\",\n \"no-api\": true\n }\n]", + "description": "rtcamp/wp-phpcs is not on Packagist, so Composer only finds it through this repository. Add the entry to the existing repositories array unless the URL is already there, or create the array. Apply before installing the package." + }, + { + "target_file": "composer.json", + "anchor": "\"allow-plugins\"", + "snippet_template": "\"config\": {\n \"allow-plugins\": {\n \"dealerdirect/phpcodesniffer-composer-installer\": true\n }\n}", + "description": "rtcamp/wp-phpcs registers its standards through this Composer plugin, which Composer will not run until it is allowed. Merge the entry into the existing config.allow-plugins, or create it." + } + ], "composer_dev_dependencies": { - "squizlabs/php_codesniffer": "^3.7", - "wp-coding-standards/wpcs": "^3.0" + "rtcamp/wp-phpcs": "^1.0" }, "scripts": { "composer": { diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache index 0e736bc..85f1a9a 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache +++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/core/templates/phpcs.xml.dist.mustache @@ -1,19 +1,88 @@ - - WordPress combined standard: Core, Extra, and Docs rules from wp-coding-standards/wpcs. + + Extends rtCampWP-Basic, rtCamp's WordPress standard without strict typing. + - + + + - ./ + + + + + + + + + + + + + +{{#namespace}} +{{/namespace}} + + tests/* + + + + + + + + + + + src/blocks/*/render.php + + + src/blocks/*/render.php + + + + + tests/* + + + tests/* + + + tests/* + + + tests/* + + + + + + + + . */vendor/* */node_modules/* + + */.claude/* */build/* - */tests/* diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json index 9e80508..80ade4d 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/scaffold.json @@ -1,19 +1,63 @@ { "slug": "full", "category": "lint/phpcs", - "name": "PHPCS - rtCamp full", - "description": "PHP_CodeSniffer with rtCamp's shared standard: WordPress-Core, Extra, Docs, and VIP-Go rules via wp-framework.", + "name": "PHPCS - rtCampWP", + "description": "PHP_CodeSniffer with rtCampWP, rtCamp's strict standard from rtcamp/wp-phpcs: WordPress-Extra, Docs, VIP-Go and PHP compatibility, plus strict types, type hints and short arrays. The default for new code.", "source": "template", + "inputs": [ + { + "key": "text_domain", + "description": "Text domain every translatable string must use. Read from the plugin or theme header.", + "discover_from": "plugin-header:text-domain", + "default": "my-plugin" + }, + { + "key": "prefix", + "description": "Prefix required on global functions, hooks, constants and variables. Derived from text_domain.", + "discover_from": "input:text_domain", + "transform": "snake-case" + }, + { + "key": "namespace", + "description": "Root PHP namespace, which PHPCS also accepts as a prefix. Read from composer.json PSR-4 autoload; left out when the project has none.", + "discover_from": "composer.json:autoload.psr-4", + "default": "" + }, + { + "key": "php_version", + "description": "Lowest PHP version the project supports, e.g. 8.2. Read from the Requires PHP header.", + "discover_from": "plugin-header:requires-php", + "default": "8.2" + }, + { + "key": "wp_version", + "description": "Lowest WordPress version the project supports, e.g. 6.5. Read from the Requires at least header.", + "discover_from": "plugin-header:requires-at-least", + "default": "6.5" + } + ], "files": [ { "src": "templates/phpcs.xml.dist.mustache", "dest": "phpcs.xml.dist" } ], + "wiring": [ + { + "target_file": "composer.json", + "anchor": "\"repositories\"", + "snippet_template": "\"repositories\": [\n {\n \"type\": \"vcs\",\n \"url\": \"https://github.com/rtCamp/wp-phpcs.git\",\n \"no-api\": true\n }\n]", + "description": "rtcamp/wp-phpcs is not on Packagist, so Composer only finds it through this repository. Add the entry to the existing repositories array unless the URL is already there, or create the array. Apply before installing the package." + }, + { + "target_file": "composer.json", + "anchor": "\"allow-plugins\"", + "snippet_template": "\"config\": {\n \"allow-plugins\": {\n \"dealerdirect/phpcodesniffer-composer-installer\": true\n }\n}", + "description": "rtcamp/wp-phpcs registers its standards through this Composer plugin, which Composer will not run until it is allowed. Merge the entry into the existing config.allow-plugins, or create it." + } + ], "composer_dev_dependencies": { - "squizlabs/php_codesniffer": "^3.7", - "wp-coding-standards/wpcs": "^3.0", - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-phpcs": "^1.0" }, "scripts": { "composer": { diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache index 45dd020..944a98d 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache +++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/full/templates/phpcs.xml.dist.mustache @@ -1,32 +1,89 @@ - - Extends rtCamp shared standard: WordPress-Core, Extra, Docs, and VIP-Go rules. + + Extends rtCampWP, rtCamp's strict WordPress standard. + - + + + - ./ - */vendor/* - */node_modules/* - */build/* - */tests/* + + + + + + + - + + + + + +{{#namespace}} +{{/namespace}} + + tests/* + + + + + + + - - - - + + src/blocks/*/render.php + + + src/blocks/*/render.php + + + + tests/* + + + tests/* + + + tests/* + + + tests/* + + + + + + + + . + */vendor/* + */node_modules/* + + */.claude/* + */build/* + + diff --git a/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json index fc09690..a7f2830 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/phpcs/vip/scaffold.json @@ -13,8 +13,16 @@ "composer_dev_dependencies": { "squizlabs/php_codesniffer": "^3.7", "wp-coding-standards/wpcs": "^3.0", - "automattic/vip-coding-standards": "^3.0" + "automattic/vipwpcs": "^3.0" }, + "wiring": [ + { + "target_file": "composer.json", + "anchor": "\"allow-plugins\"", + "snippet_template": "\"config\": {\n \"allow-plugins\": {\n \"dealerdirect/phpcodesniffer-composer-installer\": true\n }\n}", + "description": "WPCS and VIPCS depend on this Composer plugin to register their standards, and Composer will not install it until it is allowed. Merge the entry into the existing config.allow-plugins, or create it. Apply before installing the packages." + } + ], "scripts": { "composer": { "lint:php": "phpcs --standard=phpcs.xml.dist", 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 876b9f6..4aed337 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 @@ -55,7 +55,7 @@ Read, in order: - Block scaffolds: sample one `block.json` for vendor prefix and source dir. - CI scaffolds: sample one `.github/workflows/*.yml` for filename and trigger style. -Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) are hints, not ground truth. Sampled patterns win. +Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) settle *where* a snippet goes, not *how* it is written - sampled patterns win on snippet shape. Note any `// wp:example:` ... `:end` regions in the files you sample; §6a will not insert inside one. Confirm findings with the developer in one short message. Proceed on confirmation. @@ -147,7 +147,7 @@ Result shape: `{ scaffold, engine, developer, ai, warnings }`. For each `{ targetFile, anchor, snippet, description }`: 1. **Snippet** - use canonical if it matches the sampled project pattern; else translate using the sampled shape with the new class substituted. Show both. If patterns conflict or no samples exist, ask. -2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction. +2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction. Never land between a `// wp:example:` opener and its `:end`, anchors included: `wp-tooling init` deletes those regions body and all when the capability is dropped, taking the wired class with them. Fall back to the last occurrence outside every region and say so. 3. **Consent** - show targetFile + line range + description + rendered snippet. Ask `[apply / different location / edit snippet / skip]`. Never apply without consent. 4. **Idempotent** - search first, do not re-insert. @@ -211,6 +211,7 @@ Escalation report format: **what you tried, what you observed, what's blocking, - Never edit branch protection, repo settings, webhooks, or any GitHub admin surface. - Never commit, push, open PRs, or comment on issues without explicit consent. - Never apply wiring without showing the diff and getting consent. +- Never place a wiring snippet inside a `// wp:example:` ... `:end` region. - Never invent a third registration pattern when canonical and sampled disagree - ask. - Never restore scaffold anchor comments without explicit consent. - Never modify `composer.json`, `package.json`, or any lockfile beyond what the engine wrote. diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json index f3d6cf4..4a89d5d 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-evals.json @@ -91,6 +91,22 @@ "The wiring snippet targets the Services module on the // scaffold:wp/registrable:classes anchor.", "The skill does NOT run composer install, composer require or npm install." ] + }, + { + "id": 7, + "prompt": "Add a service that warms the homepage cache on publish. Register it the way the other services are registered.", + "expected_output": "wp/registrable scaffolds the class and its PHPUnit test under the project's Services namespace. The wiring lands in Main::CLASSES, but NOT between a // wp:example: marker and its :end -- those regions are deleted body and all when the capability is dropped, which would silently take the new class with them. The skill inserts after the last entry that sits outside every region and says which line it chose and why.", + "files": [], + "expectations": [ + "The skill picks the wp/registrable scaffold rather than hand-writing a Registrable class.", + "The wiring snippet is NOT inserted between a // wp:example: line and its matching // wp:example::end line, even though the last existing entry in the array sits inside one.", + "The chosen insertion point is after the last entry that is outside every wp:example region -- for Main::CLASSES that is the last line of the always-loaded core block.", + "The skill explains the placement, naming the example-region hazard rather than silently picking a different line than the developer would expect.", + "Consent is requested before the wiring edit is applied, showing the target file, the line, and the snippet.", + "The class lands under the project's Services namespace and base path, read from the PSR-4 roots.", + "The generated test is expanded rather than left as the stub, and no markTestIncomplete or markTestSkipped survives.", + "The skill does NOT run composer install, composer require or npm install." + ] } ] } diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md index 28f7453..cff2e98 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md @@ -61,8 +61,9 @@ grep -rl "Plugin Name:\|Theme Name:" . --include="*.php" --exclude-dir=vendor -- **VIP indicators:** ```bash -grep -rl "WordPress-VIP-Minimum\|automattic/vip-coding-standards\|VIP_GO_ENV" . \ - --include="*.{php,xml,json,yml}" --exclude-dir=vendor --exclude-dir=node_modules | head -3 +grep -rlE "WordPressVIPMinimum|WordPress-VIP-Go|automattic/vipwpcs|VIP_GO_APP_ENVIRONMENT|VIP_GO_ENV" . \ + --include="*.php" --include="*.xml" --include="*.dist" --include="*.json" --include="*.yml" \ + --exclude-dir=vendor --exclude-dir=node_modules | head -3 ``` **Languages present:** @@ -106,7 +107,7 @@ Construct the plan in two phases: **project setup** and **feature scaffolds**. | PHP present, no PSR-4 in `composer.json` | `setup/psr4` | `autoload.psr-4` already set | | VIP project | `lint/phpcs/vip` | `phpcs.xml.dist` exists | | Non-VIP project | `lint/phpcs/full` | `phpcs.xml.dist` exists | -| Developer explicitly chose core-only PHPCS | `lint/phpcs/core` | `phpcs.xml.dist` exists | +| Non-VIP project whose existing PHP does not declare strict types | `lint/phpcs/core` | `phpcs.xml.dist` exists | | PHP present | `lint/phpstan` | `phpstan.neon.dist` exists | | JS present | `lint/eslint` | `eslint.config.js` exists | | CSS or SCSS present | `lint/stylelint` | `.stylelintrc.js` exists | @@ -150,7 +151,7 @@ Here is what I will do. Please confirm or adjust before I start. Phase A — Project setup: 1. setup/editorconfig → .editorconfig 2. setup/psr4 → wiring in composer.json (namespace: Acme\ImageOptimizer, path: includes/) - 3. lint/phpcs/vip → phpcs.xml.dist (WordPress-VIP-Minimum + WordPress-Docs) + 3. lint/phpcs/vip → phpcs.xml.dist (WordPressVIPMinimum + WordPress-Docs) 4. lint/phpstan → phpstan.neon.dist (extends rtCamp wp-phpstan baseline) 5. lint/eslint → eslint.config.js 6. setup/phpunit → phpunit.xml.dist, tests/bootstrap.php @@ -200,7 +201,7 @@ npx wp-tooling add setup/pa11y --non-interactive --json --cwd . --base-url=h Process each result before running the next: - Report files written and skipped. -- Apply `setup/psr4` wiring to `composer.json` with explicit consent (see §Wiring below). +- Apply `setup/psr4` and `lint/phpcs/*` wiring to `composer.json` with explicit consent (see §Wiring below). - Accumulate `developer.install.*` and `developer.scripts.*` across all scaffolds. ### 5. Execute Phase B @@ -262,6 +263,15 @@ composer.json not found. I can create a minimal one: Create it? [yes / skip PSR-4 / give me the values to use] ``` +### Wiring: composer.json for PHPCS + +The `lint/phpcs/*` wiring entries must be in `composer.json` before the developer runs `composer require`, or the install fails: + +1. `repositories` (`full` and `core` only) — a `vcs` entry for `https://github.com/rtCamp/wp-phpcs.git`, since `rtcamp/wp-phpcs` is not on Packagist. Append it to an existing array; skip it if that URL is already listed. `vip` needs none: `automattic/vipwpcs` is on Packagist. +2. `config.allow-plugins` (all three) — `dealerdirect/phpcodesniffer-composer-installer: true`, the plugin that registers the standards. Merge it into an existing `config` block. + +Show the changes together, ask once, and merge rather than replace existing keys. + ### Wiring: feature scaffolds Same as `skills/scaffold.md` §5 — `ai.wiring`. Show diff, get consent, apply. @@ -295,7 +305,7 @@ Developer actions (run these yourself): composer dump-autoload --optimize composer require --dev \ - automattic/vip-coding-standards:^3.0 \ + automattic/vipwpcs:^3.0 \ wp-coding-standards/wpcs:^3.0 \ squizlabs/php_codesniffer:^3.7 \ phpunit/phpunit:^12.0 \ @@ -335,11 +345,11 @@ Deduplicate packages. Sort alphabetically within each block. Pinned packages use | Scaffold ID | When to use | Ruleset | |---|---|---| -| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `vendor/rtcamp/wp-framework/phpcs.xml.dist`, WordPress-Core + Extra + Docs + VIP-Go | -| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPress-VIP-Minimum` + `WordPress-Docs` | -| `lint/phpcs/core` | Projects explicitly opting out of VIP-Go rules | `WordPress` — Core + Extra + Docs only | +| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `rtCampWP` from `rtcamp/wp-phpcs`: WordPress-Extra + Docs + VIP-Go + PHPCompatibilityWP, plus strict types, type hints and short arrays | +| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPressVIPMinimum` + `WordPress-Docs` | +| `lint/phpcs/core` | Existing code not yet ready for strict types | `rtCampWP-Basic` from `rtcamp/wp-phpcs`: the same rules without the strict layer | -Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard. +`full` and `core` read `testVersion` and `minimum_wp_version` from the `Requires PHP` and `Requires at least` headers, the text domain from `Text Domain`, and the namespace prefix from `composer.json` PSR-4 autoload. Cite the values in the plan. Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard. ## Test scaffolds reference diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php index 21d12c7..b8ede5d 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php @@ -1,4 +1,4 @@ -] []' . PHP_EOL ); } @@ -38,7 +44,7 @@ : ( function_exists( 'tideways_xhprof_enable' ) ? 'tideways' : 'none' ); if ( ! class_exists( \rtCamp\WPDevTools\Support\XHProfProfiler::class ) ) { - echo wp_json_encode( array() ) . PHP_EOL; + echo wp_json_encode( [] ) . PHP_EOL; fwrite( STDERR, sprintf( diff --git a/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json index feee7b1..7d1f8fa 100644 --- a/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/setup/phpunit/scaffold.json @@ -19,6 +19,12 @@ "key": "source_dir", "description": "Directory containing the PHP source to include in coverage (relative to project root, no trailing slash).", "default": "includes" + }, + { + "key": "tests_namespace", + "description": "PSR-4 namespace of the test suite, used as the bootstrap's @package. Default 'Inc\\Tests'.", + "discover_from": "composer.json:autoload.psr-4", + "default": "Inc\\Tests" } ], "composer_dev_dependencies": { diff --git a/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache b/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache index 1c5d38d..aaea32e 100644 --- a/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache +++ b/node-packages/wp-tooling/scaffolds/setup/phpunit/templates/bootstrap.php.mustache @@ -2,8 +2,12 @@ /** * PHPUnit bootstrap: loads Composer autoloader and optionally the * WordPress test suite when WP_TESTS_DIR is set. + * + * @package {{tests_namespace}} */ +declare( strict_types = 1 ); + require_once dirname( __DIR__ ) . '/vendor/autoload.php'; // Integration tests: load the WordPress test library. @@ -14,8 +18,7 @@ if ( $wp_tests_dir ) { tests_add_filter( 'muplugins_loaded', static function () { - // Load the plugin under test here. - // require dirname( __DIR__ ) . '/plugin-name.php'; + // Require the plugin under test here, e.g. dirname( __DIR__ ) . '/plugin-name.php'. } ); diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json index 3ccea71..4b7d35b 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json @@ -27,6 +27,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Services'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Services" }, { @@ -61,6 +62,12 @@ "description": "Text domain for the label string. The engine cannot read a plugin header, so pass it explicitly when it is not 'my-plugin'.", "discover_from": "plugin-header:text-domain", "default": "my-plugin" + }, + { + "key": "label_php", + "description": "Escaped label for generated string literals. Derived from label.", + "discover_from": "input:label", + "transform": "php-escape" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache index dc8f741..374032a 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache @@ -67,7 +67,7 @@ final class {{class}} implements Registrable { register_block_bindings_source( self::SOURCE_NAME, [ - 'label' => __( '{{label}}', '{{text_domain}}' ), + 'label' => __( '{{label_php}}', '{{text_domain}}' ), 'get_value_callback' => [ $this, 'get_value' ], 'uses_context' => self::USES_CONTEXT, ] diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json index 4bd5829..9b5bbfc 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json @@ -14,7 +14,7 @@ }, { "key": "base_path", - "description": "Directory for the registration class. Default 'includes/Services'. The class derives the plugin root by walking two levels up from itself, so keep it two levels deep or adjust the dirname() depth it documents.", + "description": "Directory for the registration class. Default 'includes/Services'. The project root is derived from this path.", "discover_from": "composer.json:autoload.psr-4", "default": "includes/Services" }, @@ -27,6 +27,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Services'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Services" }, { @@ -60,13 +61,22 @@ "key": "enqueue_hook", "description": "Hook the module is enqueued on: 'wp_enqueue_scripts' for front-end requests, 'enqueue_block_assets' to cover the editor as well.", "default": "wp_enqueue_scripts", - "enum": ["wp_enqueue_scripts", "enqueue_block_assets"] + "enum": [ + "wp_enqueue_scripts", + "enqueue_block_assets" + ] }, { "key": "text_domain", "description": "Vendor half of the module id, conventionally the project text domain, producing '@my-plugin/lightbox'. The engine cannot read a plugin header, so pass it explicitly when it is not 'my-plugin'.", "discover_from": "plugin-header:text-domain", "default": "my-plugin" + }, + { + "key": "project_root", + "description": "PHP expression for the project root, derived from base_path.", + "discover_from": "input:base_path", + "transform": "php-project-root" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache index 0ba8b5d..341309d 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache @@ -94,24 +94,22 @@ final class {{class}} implements Registrable { /** * Filesystem path of the built module. * - * Assumes this class sits two levels below the plugin root (the default - * '/includes/Services/' layout). Adjust the dirname() depths here - * and in get_module_url() together if the class lives somewhere else. + * The dirname() depth is derived from the scaffold's base_path, so a + * custom autoload directory still resolves to the project root. */ public function get_module_path(): string { - return dirname( __DIR__, 2 ) . '/{{build_dir}}/{{module_slug}}.js'; + return {{project_root}} . '/{{build_dir}}/{{module_slug}}.js'; } /** * Public URL of the built module. * - * The dirname() depth is one shallower than get_module_path()'s on - * purpose: plugin_dir_url() takes a *file* and returns the URL of the - * directory holding it, so passing '/includes' yields the plugin - * root URL. Both therefore describe the same directory. + * Plugin_dir_url() takes a file path and returns its directory's URL. + * A file name appended to the derived project root gives it that directory; + * no file is read, so the synthetic root-level path need not exist. */ public function get_module_url(): string { - return plugin_dir_url( dirname( __DIR__ ) ) . '{{build_dir}}/{{module_slug}}.js'; + return plugin_dir_url( {{project_root}} . '/{{class}}.php' ) . '{{build_dir}}/{{module_slug}}.js'; } /** @@ -120,7 +118,7 @@ final class {{class}} implements Registrable { * @return array */ private function get_asset_meta(): array { - $asset_file = dirname( __DIR__, 2 ) . '/{{build_dir}}/{{module_slug}}.asset.php'; + $asset_file = {{project_root}} . '/{{build_dir}}/{{module_slug}}.asset.php'; if ( ! is_readable( $asset_file ) ) { return []; @@ -139,7 +137,7 @@ final class {{class}} implements Registrable { * * @param array $dependencies Raw dependency list. * - * @return array> + * @return array */ private function normalise_dependencies( array $dependencies ): array { $normalised = []; @@ -150,9 +148,19 @@ final class {{class}} implements Registrable { continue; } - if ( is_array( $dependency ) && isset( $dependency['id'] ) && is_string( $dependency['id'] ) ) { - $normalised[] = $dependency; + if ( ! is_array( $dependency ) || ! isset( $dependency['id'] ) || ! is_string( $dependency['id'] ) ) { + continue; } + + // Rebuilt key by key rather than passed through, so the result is + // exactly the shape core documents -- anything else it would ignore. + $entry = [ 'id' => $dependency['id'] ]; + + if ( isset( $dependency['import'] ) && is_string( $dependency['import'] ) ) { + $entry['import'] = $dependency['import']; + } + + $normalised[] = $entry; } return $normalised; diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json index 0c39c93..06951f6 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json @@ -27,6 +27,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Services'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Services" }, { diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json index d2e7f1e..0a9ee5b 100644 --- a/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Admin'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Admin" }, { @@ -59,6 +60,18 @@ "key": "parent_slug", "description": "Parent menu slug for a submenu (e.g. 'options-general.php'). Leave empty for a top-level menu.", "default": "" + }, + { + "key": "menu_title_php", + "description": "Escaped menu_title for generated string literals. Derived from menu_title.", + "discover_from": "input:menu_title", + "transform": "php-escape" + }, + { + "key": "page_title_php", + "description": "Escaped page_title for generated string literals. Derived from page_title.", + "discover_from": "input:page_title", + "transform": "php-escape" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache index 952231a..4a0b7fa 100644 --- a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -22,8 +22,6 @@ class {{class}} extends AbstractAdminPage { /** * Get the menu slug (page identifier). - * - * @return string */ public static function get_slug(): string { return '{{slug}}'; @@ -31,20 +29,16 @@ class {{class}} extends AbstractAdminPage { /** * Get the page title (shown in the browser tab). - * - * @return string */ public function get_page_title(): string { - return __( '{{page_title}}', '{{text_domain}}' ); + return __( '{{page_title_php}}', '{{text_domain}}' ); } /** * Get the menu title (shown in the admin sidebar). - * - * @return string */ public function get_menu_title(): string { - return __( '{{menu_title}}', '{{text_domain}}' ); + return __( '{{menu_title_php}}', '{{text_domain}}' ); } {{#parent_slug}} /** @@ -55,8 +49,6 @@ class {{class}} extends AbstractAdminPage { * returning null) applies: returning a concrete value from a ?string * override would make the return type too wide for PHPStan level 5's * checkTooWideReturnTypesInProtectedAndPublicMethods rule. - * - * @return string */ public function get_parent_slug(): string { return '{{parent_slug}}'; @@ -64,8 +56,6 @@ class {{class}} extends AbstractAdminPage { {{/parent_slug}} /** * Capability required to view the page. - * - * @return string */ public function get_capability(): string { return 'manage_options'; @@ -73,8 +63,6 @@ class {{class}} extends AbstractAdminPage { /** * Render the page body. Replace with your markup. - * - * @return void */ public function render(): void { ?> diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache index bcad0bf..12e0b35 100644 --- a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,14 +17,22 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The slug the class exposes is the one the page is registered under. + */ public function test_slug_matches_manifest(): void { $this->assertSame( '{{slug}}', {{class}}::get_slug() ); } + /** + * Registration is deferred to admin_menu, where add_menu_page() is available. + */ public function test_register_hooks_attaches_to_admin_menu(): void { $instance = new {{class}}(); $instance->register_hooks(); diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json index 0d2c9f8..58725da 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Blocks'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Blocks" }, { @@ -58,6 +59,30 @@ "key": "vendor", "description": "Vendor prefix for the block name, e.g. 'rtcamp' produces 'rtcamp/hero-banner'.", "default": "rtcamp" + }, + { + "key": "title_js", + "description": "Escaped title for generated string literals. Derived from title.", + "discover_from": "input:title", + "transform": "js-string" + }, + { + "key": "title_json", + "description": "Escaped title for generated string literals. Derived from title.", + "discover_from": "input:title", + "transform": "json-escape" + }, + { + "key": "title_php", + "description": "Escaped title for generated string literals. Derived from title.", + "discover_from": "input:title", + "transform": "php-escape" + }, + { + "key": "project_root", + "description": "PHP expression for the project root, derived from base_path.", + "discover_from": "input:base_path", + "transform": "php-project-root" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache index 468edb5..ccc7518 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache @@ -9,12 +9,12 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractBlock; use WP_Block; +use rtCamp\WPFramework\Contracts\Abstracts\AbstractBlock; /** * Registers the {{vendor}}/{{slug}} dynamic block. @@ -23,8 +23,6 @@ class {{class}} extends AbstractBlock { /** * Get the block name (including namespace). - * - * @return string */ public static function get_name(): string { return '{{vendor}}/{{slug}}'; @@ -34,16 +32,17 @@ class {{class}} extends AbstractBlock { * Directory containing the compiled block.json + bundled index.js that * WordPress registers. Points at the build output (not the source tree), * so the editor receives the bundled assets and not raw ES modules. - * Run `npm run build` to populate this directory. + * Run `npm run build` to populate this directory. The root depth follows + * base_path, including nested and root-level autoload directories. * - * Narrowed to a non-null `string` (the parent declares `?string`): this - * override always returns a concrete path, and an always-non-null `?string` - * trips PHPStan level 5's checkTooWideReturnTypesInProtectedAndPublicMethods. - * - * @return string + * Protected to match AbstractBlock, which declares it protected and calls it + * internally. Narrowed to a non-null `string` (the parent declares + * `?string`): this override always returns a concrete path, and an + * always-non-null `?string` trips PHPStan's + * checkTooWideReturnTypesInProtectedAndPublicMethods. */ - public function get_block_dir(): string { - return dirname( __DIR__, 2 ) . '/{{build_dir}}/{{slug}}'; + protected function get_block_dir(): string { + return {{project_root}} . '/{{build_dir}}/{{slug}}'; } /** @@ -52,14 +51,12 @@ class {{class}} extends AbstractBlock { * @param array $attributes Block attributes from block.json. * @param string $content Inner content rendered by Gutenberg. * @param WP_Block $block The block instance. - * - * @return string */ public function render( array $attributes, string $content, WP_Block $block ): string { // Replace with your markup. return sprintf( '
%s
', - esc_html( $attributes['title'] ?? '{{title}}' ) + esc_html( $attributes['title'] ?? '{{title_php}}' ) ); } } diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block.json.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block.json.mustache index 3e249df..c42fca1 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block.json.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block.json.mustache @@ -2,10 +2,10 @@ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "{{vendor}}/{{slug}}", - "title": "{{title}}", + "title": "{{title_json}}", "category": "widgets", "icon": "block-default", - "description": "{{title}} block.", + "description": "{{title_json}} block.", "keywords": ["{{slug}}"], "supports": { "html": false diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/edit.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/edit.js.mustache index 33bcfb0..0bab1fa 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/edit.js.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/edit.js.mustache @@ -9,8 +9,8 @@ import { __ } from '@wordpress/i18n'; export default function Edit() { const blockProps = useBlockProps(); return ( -
-

{ __( '{{title}}', '{{vendor}}-{{slug}}' ) }

+
+

{__({{title_js}}, '{{vendor}}-{{slug}}')}

); } diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache index 3c7ccb4..ad38839 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/index.js.mustache @@ -7,6 +7,6 @@ import { registerBlockType } from '@wordpress/blocks'; import Edit from './edit'; import metadata from './block.json'; -registerBlockType( metadata.name, { +registerBlockType(metadata.name, { edit: Edit, -} ); +}); diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache index fa7e50c..278e68d 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,14 +17,22 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The block name carries the vendor prefix core matches against block.json. + */ public function test_block_name_matches_manifest(): void { $this->assertSame( '{{vendor}}/{{slug}}', {{class}}::get_name() ); } + /** + * Registration is deferred to init, the earliest hook a block type may register on. + */ public function test_register_hooks_attaches_to_init(): void { $instance = new {{class}}(); $instance->register_hooks(); diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json index 1a232bd..413f1ae 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Blocks'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Blocks" }, { @@ -58,6 +59,24 @@ "key": "vendor", "description": "Vendor prefix for the block name, e.g. 'rtcamp' produces 'rtcamp/faq-accordion'. Also the store namespace shared by render.php's data-wp-interactive and view.js's store().", "default": "rtcamp" + }, + { + "key": "title_js", + "description": "Escaped title for generated string literals. Derived from title.", + "discover_from": "input:title", + "transform": "js-string" + }, + { + "key": "title_json", + "description": "Escaped title for generated string literals. Derived from title.", + "discover_from": "input:title", + "transform": "json-escape" + }, + { + "key": "project_root", + "description": "PHP expression for the project root, derived from base_path.", + "discover_from": "input:base_path", + "transform": "php-project-root" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache index 499bc6c..d26d8a5 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache @@ -45,7 +45,7 @@ final class {{class}} implements Registrable { * (`npm run build:blocks` in the rtCamp skeleton) to populate it. */ public function get_block_dir(): string { - return dirname( __DIR__, 2 ) . '/{{build_dir}}/{{slug}}'; + return {{project_root}} . '/{{build_dir}}/{{slug}}'; } /** diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache index e4c61f6..6eb0471 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block.json.mustache @@ -2,10 +2,10 @@ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "{{vendor}}/{{slug}}", - "title": "{{title}}", + "title": "{{title_json}}", "category": "widgets", "icon": "block-default", - "description": "{{title}} block, made interactive with the Interactivity API.", + "description": "{{title_json}} block, made interactive with the Interactivity API.", "keywords": ["{{slug}}"], "textdomain": "{{vendor}}-{{slug}}", "supports": { diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache index 0b31f16..5cc7e1e 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/edit.js.mustache @@ -12,8 +12,8 @@ import { __ } from '@wordpress/i18n'; export default function Edit() { const blockProps = useBlockProps(); return ( -
-

{ __( '{{title}}', '{{vendor}}-{{slug}}' ) }

+
+

{__({{title_js}}, '{{vendor}}-{{slug}}')}

); } diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache index 3c7ccb4..ad38839 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/index.js.mustache @@ -7,6 +7,6 @@ import { registerBlockType } from '@wordpress/blocks'; import Edit from './edit'; import metadata from './block.json'; -registerBlockType( metadata.name, { +registerBlockType(metadata.name, { edit: Edit, -} ); +}); diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache index 6a8d106..3543f4c 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache @@ -73,8 +73,8 @@ final class {{class}}Test extends WP_UnitTestCase { } /** - * register_block() is safe to call whatever the environment: before WP 6.5 - * or before a build it registers nothing instead of failing. + * Registration is safe to call whatever the environment: before WP 6.5 or + * before a build, register_block() registers nothing instead of failing. */ public function test_register_block_is_a_no_op_without_build_output(): void { $instance = new {{class}}(); diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache index 8c65b34..04d7dca 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/view.js.mustache @@ -19,11 +19,11 @@ */ import { store, getContext } from '@wordpress/interactivity'; -store( '{{vendor}}/{{slug}}', { +store('{{vendor}}/{{slug}}', { actions: { toggle() { const context = getContext(); - context.isOpen = ! context.isOpen; + context.isOpen = !context.isOpen; }, }, -} ); +}); diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json index a665505..544a83b 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Cli'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Cli" }, { diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache index cc11f79..a952418 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache @@ -9,12 +9,12 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Interfaces\CLICommand; use WP_CLI; +use rtCamp\WPFramework\Contracts\Interfaces\CLICommand; /** * Registers the {{name}} WP-CLI command. @@ -23,8 +23,6 @@ final class {{class}} implements CLICommand { /** * Get the command name. - * - * @return string */ public static function get_name(): string { return '{{name}}'; @@ -32,8 +30,6 @@ final class {{class}} implements CLICommand { /** * Get the command description. - * - * @return string */ public static function get_description(): string { return '{{description}}'; @@ -49,8 +45,6 @@ final class {{class}} implements CLICommand { * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. - * - * @return void */ public static function run( array $args, array $assoc_args ): void { $dry_run = isset( $assoc_args['dry-run'] ); diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache index f491f1b..2547fdc 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache @@ -5,29 +5,42 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; use {{namespace}}\{{class}}; -use rtCamp\WPFramework\Contracts\Interfaces\CLICommand; use PHPUnit\Framework\TestCase; +use rtCamp\WPFramework\Contracts\Interfaces\CLICommand; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends TestCase { + /** + * The class satisfies the framework's CLICommand contract, so the CLI module can boot it. + */ public function test_implements_cli_command(): void { $this->assertTrue( in_array( CLICommand::class, class_implements( {{class}}::class ), true ) ); } + /** + * The command name is the one WP_CLI dispatches on. + */ public function test_name_matches_manifest(): void { $this->assertSame( '{{name}}', {{class}}::get_name() ); } + /** + * WP_CLI prints the description in `wp help`, so an empty one ships a blank entry. + */ public function test_description_is_non_empty(): void { $this->assertNotEmpty( {{class}}::get_description() ); } diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json index 679acaa..feb100d 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/PostTypes'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/PostTypes" }, { @@ -59,6 +60,18 @@ "key": "menu_icon", "description": "Dashicon class or SVG URL for the admin sidebar.", "default": "dashicons-admin-post" + }, + { + "key": "singular_php", + "description": "Escaped singular for generated string literals. Derived from singular.", + "discover_from": "input:singular", + "transform": "php-escape" + }, + { + "key": "plural_php", + "description": "Escaped plural for generated string literals. Derived from plural.", + "discover_from": "input:plural", + "transform": "php-escape" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache index 3253558..6b9b0a3 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -22,8 +22,6 @@ class {{class}} extends AbstractPostType { /** * Get the post type slug. - * - * @return string */ public static function get_slug(): string { return '{{slug}}'; @@ -31,26 +29,20 @@ class {{class}} extends AbstractPostType { /** * Get the singular label. - * - * @return string */ public function get_singular_label(): string { - return __( '{{singular}}', '{{text_domain}}' ); + return __( '{{singular_php}}', '{{text_domain}}' ) ?: '{{singular_php}}'; } /** * Get the plural label. - * - * @return string */ public function get_plural_label(): string { - return __( '{{plural}}', '{{text_domain}}' ); + return __( '{{plural_php}}', '{{text_domain}}' ) ?: '{{plural_php}}'; } /** * Get the menu icon (Dashicon class, SVG data URI, or 'none'). - * - * @return string */ public function get_menu_icon(): string { return '{{menu_icon}}'; diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache index ee8864c..64ad4ae 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,20 +17,31 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The post type key is the one the class registers under. + */ public function test_slug_matches_manifest(): void { $this->assertSame( '{{slug}}', {{class}}::get_slug() ); } + /** + * Registration is deferred to init, where register_post_type() is available. + */ public function test_register_hooks_attaches_to_init(): void { $instance = new {{class}}(); $instance->register_hooks(); $this->assertNotFalse( has_action( 'init', [ $instance, 'register' ] ) ); } + /** + * Registration actually lands in core's post type registry. + */ public function test_post_type_is_registered_after_init(): void { ( new {{class}}() )->register(); $this->assertTrue( post_type_exists( '{{slug}}' ) ); diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json index a59783c..8676051 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Cron'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Cron" }, { diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache index 3d1ce6f..2cc845b 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -26,8 +26,6 @@ final class {{class}} implements Registrable { /** * Schedule the event (if not already scheduled) and bind the handler. * Called by the framework Loader. - * - * @return void */ public function register_hooks(): void { if ( ! wp_next_scheduled( self::HOOK ) ) { @@ -38,8 +36,6 @@ final class {{class}} implements Registrable { /** * The job body. Called every time the hook fires. - * - * @return void */ public function run(): void { // Replace with your job logic. diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache index c4c30eb..ae71bfd 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,16 +17,24 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * Booting the class schedules its event, so the job runs unattended. + */ public function test_register_hooks_schedules_event(): void { ( new {{class}}() )->register_hooks(); $this->assertNotFalse( wp_next_scheduled( {{class}}::HOOK ) ); wp_clear_scheduled_hook( {{class}}::HOOK ); } + /** + * The scheduled hook resolves to this class's handler, not just to an empty event. + */ public function test_register_hooks_binds_run_handler(): void { $instance = new {{class}}(); $instance->register_hooks(); diff --git a/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json index e439357..83437b7 100644 --- a/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Modules'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Modules" }, { diff --git a/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache b/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache index 7474bf4..ce13a1c 100644 --- a/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -26,7 +26,7 @@ final class {{class}} extends AbstractModule { * * @return array */ - public function get_classes(): array { + protected function get_classes(): array { return [ // scaffold:wp/{{kind}}:classes (add classes here). ]; diff --git a/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache index 5f63cfb..30cc3c6 100644 --- a/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache @@ -5,27 +5,65 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; use {{namespace}}\{{class}}; +use ReflectionMethod; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { - public function test_get_classes_returns_array(): void { - $this->assertIsArray( ( new {{class}}() )->get_classes() ); + /** + * The Loader walks the list positionally, so it must be a list. + */ + public function test_get_classes_returns_a_list(): void { + $classes = $this->get_module_classes(); + + $this->assertSame( array_values( $classes ), $classes, 'get_classes() must return a list, not a keyed map.' ); } - public function test_each_listed_class_is_loadable(): void { - foreach ( ( new {{class}}() )->get_classes() as $class ) { - $this->assertTrue( class_exists( $class ), "Class {$class} should be loadable" ); + /** + * Every listed class is autoloadable, so the Loader can instantiate it. + * + * Compares whole arrays rather than asserting inside the loop: a freshly + * scaffolded module lists nothing, and a loop body that never runs leaves + * the case with no assertion at all -- risky, which consumers fail on. + */ + public function test_every_listed_class_is_loadable(): void { + $classes = $this->get_module_classes(); + $loadable = []; + + foreach ( $classes as $class ) { + if ( class_exists( $class ) ) { + $loadable[] = $class; + } } + + $this->assertSame( $classes, $loadable, 'Every class listed by get_classes() must be autoloadable.' ); + } + + /** + * Read the module's class list. + * + * AbstractModule declares get_classes() protected and the generated module + * is final, so neither a direct call nor a test subclass can reach it. + * + * @return array + */ + private function get_module_classes(): array { + $method = new ReflectionMethod( {{class}}::class, 'get_classes' ); + + return (array) $method->invoke( new {{class}}() ); } } diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json index b0fcda8..7ae37a8 100644 --- a/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Services'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Services" }, { diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache index 5027f5c..1afd97e 100644 --- a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -22,8 +22,6 @@ final class {{class}} implements Registrable { /** * Register WordPress hooks. Called by the framework Loader during boot. - * - * @return void */ public function register_hooks(): void { // Register actions and filters here, for example with add_action() or add_filter(). diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache index 0352dbd..bf455c4 100644 --- a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache @@ -5,25 +5,37 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; use {{namespace}}\{{class}}; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; use WP_UnitTestCase; +use rtCamp\WPFramework\Contracts\Interfaces\Registrable; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The class is a Registrable, so the framework Loader boots it. + */ public function test_implements_registrable(): void { $this->assertInstanceOf( Registrable::class, new {{class}}() ); } + /** + * Booting the class is side-effect free while its hooks are still empty. + * + * Replace this with assertions on the hooks register_hooks() actually adds. + */ public function test_register_hooks_runs_without_error(): void { ( new {{class}}() )->register_hooks(); $this->addToAssertionCount( 1 ); diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json index 1caf392..2f671a9 100644 --- a/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Rest'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Rest" }, { diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache b/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache index f6198a6..dd1ffa4 100644 --- a/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -43,8 +43,6 @@ class {{class}}Controller extends AbstractRESTController { /** * Register REST routes. Called by the framework on rest_api_init. - * - * @return void */ public function register_routes(): void { register_rest_route( @@ -85,7 +83,7 @@ class {{class}}Controller extends AbstractRESTController { * * @param \WP_REST_Request $request The REST request. * - * @return bool + * @return bool True when the current user may read the collection. */ public function get_items_permissions_check( $request ) { return current_user_can( 'read' ); diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache index 75803df..d046286 100644 --- a/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,18 +17,31 @@ use {{namespace}}\{{class}}Controller; use WP_UnitTestCase; /** + * Class {{class}}ControllerTest + * * @covers \{{namespace}}\{{class}}Controller */ final class {{class}}ControllerTest extends WP_UnitTestCase { + /** + * Routes are registered on rest_api_init, the only hook where register_rest_route() is safe. + */ public function test_register_hooks_attaches_to_rest_api_init(): void { $instance = new {{class}}Controller(); $instance->register_hooks(); $this->assertNotFalse( has_action( 'rest_api_init', [ $instance, 'register_routes' ] ) ); } + /** + * The route lands in the REST server's table under its namespaced path. + * + * Goes through the hook rather than calling register_routes() directly: + * register_rest_route() emits a _doing_it_wrong notice outside rest_api_init, + * and WP_UnitTestCase fails the case on an unexpected notice. + */ public function test_route_is_registered_after_rest_api_init(): void { - ( new {{class}}Controller() )->register_routes(); + ( new {{class}}Controller() )->register_hooks(); + do_action( 'rest_api_init' ); $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/{{rest_namespace}}/v{{rest_version}}/{{name}}', $routes ); } diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json index 3d813c5..a88378f 100644 --- a/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Settings'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Settings" }, { @@ -59,6 +60,18 @@ "key": "option_name", "description": "Sample option_name registered via register_setting(). Add more by editing get_settings().", "required": true + }, + { + "key": "menu_title_php", + "description": "Escaped menu_title for generated string literals. Derived from menu_title.", + "discover_from": "input:menu_title", + "transform": "php-escape" + }, + { + "key": "page_title_php", + "description": "Escaped page_title for generated string literals. Derived from page_title.", + "discover_from": "input:page_title", + "transform": "php-escape" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache index 9576034..526513f 100644 --- a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -22,8 +22,6 @@ class {{class}} extends AbstractSettingsPage { /** * Get the menu/page slug. - * - * @return string */ public static function get_slug(): string { return '{{slug}}'; @@ -31,20 +29,16 @@ class {{class}} extends AbstractSettingsPage { /** * Get the page title (shown in the browser tab). - * - * @return string */ public function get_page_title(): string { - return __( '{{page_title}}', '{{text_domain}}' ); + return __( '{{page_title_php}}', '{{text_domain}}' ); } /** * Get the menu title (shown in the admin sidebar). - * - * @return string */ public function get_menu_title(): string { - return __( '{{menu_title}}', '{{text_domain}}' ); + return __( '{{menu_title_php}}', '{{text_domain}}' ); } /** @@ -69,8 +63,6 @@ class {{class}} extends AbstractSettingsPage { /** * Render the page form. Uses Settings API helpers. - * - * @return void */ public function render(): void { ?> diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache index a74f2dc..ce31cd4 100644 --- a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,21 +17,35 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The page slug is the one the settings page is registered under. + */ public function test_slug_matches_manifest(): void { $this->assertSame( '{{slug}}', {{class}}::get_slug() ); } + /** + * The settings map is an array the registration loop can walk. + */ public function test_get_settings_returns_array(): void { $this->assertIsArray( ( new {{class}}() )->get_settings() ); } + /** + * The option reaches core's settings registry, so the Settings API renders and sanitises it. + * + * Calls the registration callback directly. Firing do_action( 'admin_init' ) + * would re-run core's own admin_init listeners, which send headers the test + * harness has already flushed. + */ public function test_sample_option_is_registered(): void { - ( new {{class}}() )->register_hooks(); - do_action( 'admin_init' ); + ( new {{class}}() )->register_settings(); $this->assertArrayHasKey( '{{option_name}}', get_registered_settings() ); } } diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json index 8520b38..dd8e074 100644 --- a/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Shortcodes'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Shortcodes" }, { diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache index b332ff8..16fd1e6 100644 --- a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -22,8 +22,6 @@ class {{class}} extends AbstractShortcode { /** * Get the shortcode tag. - * - * @return string */ public static function get_tag(): string { return '{{tag}}'; @@ -43,8 +41,6 @@ class {{class}} extends AbstractShortcode { * * @param array $atts Parsed attributes. * @param string|null $content Inner content for enclosing shortcodes. - * - * @return string */ public function render( array $atts, ?string $content ): string { // Replace with your markup. diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache index b1270fa..b3400e3 100644 --- a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,17 +17,29 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The tag is the one authors type in content. + */ public function test_tag_matches_manifest(): void { $this->assertSame( '{{tag}}', {{class}}::get_tag() ); } + /** + * The tag lands in core's shortcode registry. + * + * Calls the registration callback directly. Firing do_action( 'init' ) would + * re-run every other listener on that hook -- in a real project that means + * duplicate block registrations and _doing_it_wrong notices, which + * WP_UnitTestCase fails on. + */ public function test_register_hooks_registers_shortcode(): void { - ( new {{class}}() )->register_hooks(); - do_action( 'init' ); + ( new {{class}}() )->register_shortcode(); $this->assertTrue( shortcode_exists( '{{tag}}' ) ); } } diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json index bfb9002..a3bdc5a 100644 --- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Taxonomies'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Taxonomies" }, { @@ -59,6 +60,18 @@ "key": "object_type", "description": "Post type slug this taxonomy attaches to. For multiple, comma-separate (e.g. 'product,page').", "required": true + }, + { + "key": "singular_php", + "description": "Escaped singular for generated string literals. Derived from singular.", + "discover_from": "input:singular", + "transform": "php-escape" + }, + { + "key": "plural_php", + "description": "Escaped plural for generated string literals. Derived from plural.", + "discover_from": "input:plural", + "transform": "php-escape" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache index 37c1875..519d1b9 100644 --- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache @@ -9,7 +9,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -22,8 +22,6 @@ class {{class}} extends AbstractTaxonomy { /** * Get the taxonomy slug. - * - * @return string */ public static function get_slug(): string { return '{{slug}}'; @@ -40,27 +38,21 @@ class {{class}} extends AbstractTaxonomy { /** * Get the singular label. - * - * @return string */ public function get_singular_label(): string { - return __( '{{singular}}', '{{text_domain}}' ); + return __( '{{singular_php}}', '{{text_domain}}' ) ?: '{{singular_php}}'; } /** * Get the plural label. - * - * @return string */ public function get_plural_label(): string { - return __( '{{plural}}', '{{text_domain}}' ); + return __( '{{plural_php}}', '{{text_domain}}' ) ?: '{{plural_php}}'; } /** * Hierarchical (true = like categories) or flat (false = like tags). * Override to switch behaviour. - * - * @return bool */ public function is_hierarchical(): bool { return true; diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache index 541fbf4..e93188c 100644 --- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,20 +17,31 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The taxonomy key is the one the class registers under. + */ public function test_slug_matches_manifest(): void { $this->assertSame( '{{slug}}', {{class}}::get_slug() ); } + /** + * Registration is deferred to init, where register_taxonomy() is available. + */ public function test_register_hooks_attaches_to_init(): void { $instance = new {{class}}(); $instance->register_hooks(); $this->assertNotFalse( has_action( 'init', [ $instance, 'register' ] ) ); } + /** + * Registration actually lands in core's taxonomy registry. + */ public function test_taxonomy_is_registered_after_init(): void { ( new {{class}}() )->register(); $this->assertTrue( taxonomy_exists( '{{slug}}' ) ); diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json index 6760751..319fb1a 100644 --- a/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json @@ -26,6 +26,7 @@ { "key": "tests_path", "description": "Directory for the test file. Default 'tests/Roles'.", + "discover_from": "composer.json:autoload-dev.psr-4", "default": "tests/Roles" }, { @@ -49,6 +50,12 @@ "key": "display_name", "description": "Human-readable role name, e.g. 'Shop Manager'.", "required": true + }, + { + "key": "display_name_php", + "description": "Escaped display_name for generated string literals. Derived from display_name.", + "discover_from": "input:display_name", + "transform": "php-escape" } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache index 60b967c..a478d9b 100644 --- a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache @@ -5,9 +5,11 @@ * Scaffolded by @rtcamp/wp-tooling. * * @link https://github.com/rtCamp/wp-framework + * + * @package {{tests_namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{tests_namespace}}; @@ -15,18 +17,29 @@ use {{namespace}}\{{class}}; use WP_UnitTestCase; /** + * Class {{class}}Test + * * @covers \{{namespace}}\{{class}} */ final class {{class}}Test extends WP_UnitTestCase { + /** + * The role key is the one capabilities are granted against. + */ public function test_slug_matches_manifest(): void { $this->assertSame( '{{slug}}', {{class}}::get_slug() ); } + /** + * The capability map is an array add_role() can consume. + */ public function test_capabilities_map_is_returned(): void { $this->assertIsArray( ( new {{class}}() )->get_capabilities() ); } + /** + * The role is created in the database, not just described in code. + */ public function test_maybe_update_role_creates_role(): void { $instance = new {{class}}(); $instance->maybe_update_role(); diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache index f59aef1..7d4449d 100644 --- a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache @@ -12,7 +12,7 @@ * @package {{namespace}} */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace {{namespace}}; @@ -25,8 +25,6 @@ class {{class}} extends AbstractUserRole { /** * Get the role slug (identifier). - * - * @return string */ public static function get_slug(): string { return '{{slug}}'; @@ -34,11 +32,9 @@ class {{class}} extends AbstractUserRole { /** * Get the display name for the role. - * - * @return string */ public function get_display_name(): string { - return __( '{{display_name}}', '{{text_domain}}' ); + return __( '{{display_name_php}}', '{{text_domain}}' ) ?: '{{display_name_php}}'; } /** @@ -54,8 +50,6 @@ class {{class}} extends AbstractUserRole { /** * Role version. Increment when get_capabilities() changes. - * - * @return int */ public function get_version(): int { return 1; diff --git a/node-packages/wp-tooling/skills/scaffold/SKILL.md b/node-packages/wp-tooling/skills/scaffold/SKILL.md index 876b9f6..4aed337 100644 --- a/node-packages/wp-tooling/skills/scaffold/SKILL.md +++ b/node-packages/wp-tooling/skills/scaffold/SKILL.md @@ -55,7 +55,7 @@ Read, in order: - Block scaffolds: sample one `block.json` for vendor prefix and source dir. - CI scaffolds: sample one `.github/workflows/*.yml` for filename and trigger style. -Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) are hints, not ground truth. Sampled patterns win. +Anchors (`// scaffold::classes`, and `// scaffold:utility/` in `Helpers/Util.php`) settle *where* a snippet goes, not *how* it is written - sampled patterns win on snippet shape. Note any `// wp:example:` ... `:end` regions in the files you sample; §6a will not insert inside one. Confirm findings with the developer in one short message. Proceed on confirmation. @@ -147,7 +147,7 @@ Result shape: `{ scaffold, engine, developer, ai, warnings }`. For each `{ targetFile, anchor, snippet, description }`: 1. **Snippet** - use canonical if it matches the sampled project pattern; else translate using the sampled shape with the new class substituted. Show both. If patterns conflict or no samples exist, ask. -2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction. +2. **Location** - anchor present → after it; else after the last sampled occurrence of the pattern; else best-effort in bootstrap method (say so); else skip and print as manual instruction. Never land between a `// wp:example:` opener and its `:end`, anchors included: `wp-tooling init` deletes those regions body and all when the capability is dropped, taking the wired class with them. Fall back to the last occurrence outside every region and say so. 3. **Consent** - show targetFile + line range + description + rendered snippet. Ask `[apply / different location / edit snippet / skip]`. Never apply without consent. 4. **Idempotent** - search first, do not re-insert. @@ -211,6 +211,7 @@ Escalation report format: **what you tried, what you observed, what's blocking, - Never edit branch protection, repo settings, webhooks, or any GitHub admin surface. - Never commit, push, open PRs, or comment on issues without explicit consent. - Never apply wiring without showing the diff and getting consent. +- Never place a wiring snippet inside a `// wp:example:` ... `:end` region. - Never invent a third registration pattern when canonical and sampled disagree - ask. - Never restore scaffold anchor comments without explicit consent. - Never modify `composer.json`, `package.json`, or any lockfile beyond what the engine wrote. diff --git a/node-packages/wp-tooling/skills/scaffold/evals/evals.json b/node-packages/wp-tooling/skills/scaffold/evals/evals.json index f3d6cf4..4a89d5d 100644 --- a/node-packages/wp-tooling/skills/scaffold/evals/evals.json +++ b/node-packages/wp-tooling/skills/scaffold/evals/evals.json @@ -91,6 +91,22 @@ "The wiring snippet targets the Services module on the // scaffold:wp/registrable:classes anchor.", "The skill does NOT run composer install, composer require or npm install." ] + }, + { + "id": 7, + "prompt": "Add a service that warms the homepage cache on publish. Register it the way the other services are registered.", + "expected_output": "wp/registrable scaffolds the class and its PHPUnit test under the project's Services namespace. The wiring lands in Main::CLASSES, but NOT between a // wp:example: marker and its :end -- those regions are deleted body and all when the capability is dropped, which would silently take the new class with them. The skill inserts after the last entry that sits outside every region and says which line it chose and why.", + "files": [], + "expectations": [ + "The skill picks the wp/registrable scaffold rather than hand-writing a Registrable class.", + "The wiring snippet is NOT inserted between a // wp:example: line and its matching // wp:example::end line, even though the last existing entry in the array sits inside one.", + "The chosen insertion point is after the last entry that is outside every wp:example region -- for Main::CLASSES that is the last line of the always-loaded core block.", + "The skill explains the placement, naming the example-region hazard rather than silently picking a different line than the developer would expect.", + "Consent is requested before the wiring edit is applied, showing the target file, the line, and the snippet.", + "The class lands under the project's Services namespace and base path, read from the PSR-4 roots.", + "The generated test is expanded rather than left as the stub, and no markTestIncomplete or markTestSkipped survives.", + "The skill does NOT run composer install, composer require or npm install." + ] } ] } diff --git a/node-packages/wp-tooling/skills/setup/SKILL.md b/node-packages/wp-tooling/skills/setup/SKILL.md index 28f7453..cff2e98 100644 --- a/node-packages/wp-tooling/skills/setup/SKILL.md +++ b/node-packages/wp-tooling/skills/setup/SKILL.md @@ -61,8 +61,9 @@ grep -rl "Plugin Name:\|Theme Name:" . --include="*.php" --exclude-dir=vendor -- **VIP indicators:** ```bash -grep -rl "WordPress-VIP-Minimum\|automattic/vip-coding-standards\|VIP_GO_ENV" . \ - --include="*.{php,xml,json,yml}" --exclude-dir=vendor --exclude-dir=node_modules | head -3 +grep -rlE "WordPressVIPMinimum|WordPress-VIP-Go|automattic/vipwpcs|VIP_GO_APP_ENVIRONMENT|VIP_GO_ENV" . \ + --include="*.php" --include="*.xml" --include="*.dist" --include="*.json" --include="*.yml" \ + --exclude-dir=vendor --exclude-dir=node_modules | head -3 ``` **Languages present:** @@ -106,7 +107,7 @@ Construct the plan in two phases: **project setup** and **feature scaffolds**. | PHP present, no PSR-4 in `composer.json` | `setup/psr4` | `autoload.psr-4` already set | | VIP project | `lint/phpcs/vip` | `phpcs.xml.dist` exists | | Non-VIP project | `lint/phpcs/full` | `phpcs.xml.dist` exists | -| Developer explicitly chose core-only PHPCS | `lint/phpcs/core` | `phpcs.xml.dist` exists | +| Non-VIP project whose existing PHP does not declare strict types | `lint/phpcs/core` | `phpcs.xml.dist` exists | | PHP present | `lint/phpstan` | `phpstan.neon.dist` exists | | JS present | `lint/eslint` | `eslint.config.js` exists | | CSS or SCSS present | `lint/stylelint` | `.stylelintrc.js` exists | @@ -150,7 +151,7 @@ Here is what I will do. Please confirm or adjust before I start. Phase A — Project setup: 1. setup/editorconfig → .editorconfig 2. setup/psr4 → wiring in composer.json (namespace: Acme\ImageOptimizer, path: includes/) - 3. lint/phpcs/vip → phpcs.xml.dist (WordPress-VIP-Minimum + WordPress-Docs) + 3. lint/phpcs/vip → phpcs.xml.dist (WordPressVIPMinimum + WordPress-Docs) 4. lint/phpstan → phpstan.neon.dist (extends rtCamp wp-phpstan baseline) 5. lint/eslint → eslint.config.js 6. setup/phpunit → phpunit.xml.dist, tests/bootstrap.php @@ -200,7 +201,7 @@ npx wp-tooling add setup/pa11y --non-interactive --json --cwd . --base-url=h Process each result before running the next: - Report files written and skipped. -- Apply `setup/psr4` wiring to `composer.json` with explicit consent (see §Wiring below). +- Apply `setup/psr4` and `lint/phpcs/*` wiring to `composer.json` with explicit consent (see §Wiring below). - Accumulate `developer.install.*` and `developer.scripts.*` across all scaffolds. ### 5. Execute Phase B @@ -262,6 +263,15 @@ composer.json not found. I can create a minimal one: Create it? [yes / skip PSR-4 / give me the values to use] ``` +### Wiring: composer.json for PHPCS + +The `lint/phpcs/*` wiring entries must be in `composer.json` before the developer runs `composer require`, or the install fails: + +1. `repositories` (`full` and `core` only) — a `vcs` entry for `https://github.com/rtCamp/wp-phpcs.git`, since `rtcamp/wp-phpcs` is not on Packagist. Append it to an existing array; skip it if that URL is already listed. `vip` needs none: `automattic/vipwpcs` is on Packagist. +2. `config.allow-plugins` (all three) — `dealerdirect/phpcodesniffer-composer-installer: true`, the plugin that registers the standards. Merge it into an existing `config` block. + +Show the changes together, ask once, and merge rather than replace existing keys. + ### Wiring: feature scaffolds Same as `skills/scaffold.md` §5 — `ai.wiring`. Show diff, get consent, apply. @@ -295,7 +305,7 @@ Developer actions (run these yourself): composer dump-autoload --optimize composer require --dev \ - automattic/vip-coding-standards:^3.0 \ + automattic/vipwpcs:^3.0 \ wp-coding-standards/wpcs:^3.0 \ squizlabs/php_codesniffer:^3.7 \ phpunit/phpunit:^12.0 \ @@ -335,11 +345,11 @@ Deduplicate packages. Sort alphabetically within each block. Pinned packages use | Scaffold ID | When to use | Ruleset | |---|---|---| -| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `vendor/rtcamp/wp-framework/phpcs.xml.dist`, WordPress-Core + Extra + Docs + VIP-Go | -| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPress-VIP-Minimum` + `WordPress-Docs` | -| `lint/phpcs/core` | Projects explicitly opting out of VIP-Go rules | `WordPress` — Core + Extra + Docs only | +| `lint/phpcs/full` | Most rtCamp projects (recommended default) | `rtCampWP` from `rtcamp/wp-phpcs`: WordPress-Extra + Docs + VIP-Go + PHPCompatibilityWP, plus strict types, type hints and short arrays | +| `lint/phpcs/vip` | WordPress VIP platform projects | `WordPressVIPMinimum` + `WordPress-Docs` | +| `lint/phpcs/core` | Existing code not yet ready for strict types | `rtCampWP-Basic` from `rtcamp/wp-phpcs`: the same rules without the strict layer | -Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard. +`full` and `core` read `testVersion` and `minimum_wp_version` from the `Requires PHP` and `Requires at least` headers, the text domain from `Text Domain`, and the namespace prefix from `composer.json` PSR-4 autoload. Cite the values in the plan. Developers can add `` entries to `phpcs.xml.dist` to override or extend the selected standard. ## Test scaffolds reference diff --git a/node-packages/wp-tooling/src/cli/commands/release-zip.js b/node-packages/wp-tooling/src/cli/commands/release-zip.js index 0a93274..4ff1ff7 100644 --- a/node-packages/wp-tooling/src/cli/commands/release-zip.js +++ b/node-packages/wp-tooling/src/cli/commands/release-zip.js @@ -10,7 +10,7 @@ 'use strict'; -const { spinner, CancelledError } = require('../../ui'); +const { spinner, warn, CancelledError } = require('../../ui'); const { zip } = require('../../release/zip'); function parseArgs(argv) { @@ -95,6 +95,9 @@ function runCli(argv) { s.succeed( `${verb} ${result.outputPath} (${result.fileCount} files, ${formatSize(result.byteSize)})` ); + for (const warning of result.warnings || []) { + warn(warning); + } return 0; } catch (err) { s.fail('Zip build failed'); diff --git a/node-packages/wp-tooling/src/release/context.js b/node-packages/wp-tooling/src/release/context.js index 9b7b908..9730002 100644 --- a/node-packages/wp-tooling/src/release/context.js +++ b/node-packages/wp-tooling/src/release/context.js @@ -5,10 +5,11 @@ * * - `package.json` (required) * - `composer.json` (optional) - * - The WordPress plugin entry file (a `*.php` at the package root - * carrying a `Plugin Name:` header) + * - The WordPress entry file: a `*.php` at the package root carrying a + * `Plugin Name:` header, or a theme's `style.css` carrying `Theme Name:` * - The current version (taken from `package.json`) - * - The plugin slug (basename of the entry file, used for the zip name) + * - The slug used for the zip name — the entry file's basename for a plugin, + * the directory name for a theme, which is what WordPress keys a theme on * * Lives as a separate module so bump / changelog / zip don't each re-walk * the directory or re-parse the same headers. @@ -29,16 +30,22 @@ const path = require('path'); const HEADER_SCAN_BYTES = 8192; /** - * Walk `cwd` (root only -- no recursion) looking for a `.php` file whose - * leading bytes contain a `Plugin Name:` header. WordPress' own - * convention is exactly one such file per plugin; this helper enforces - * that, throwing a clear error if zero or more than one is found. + * Find the project's WordPress entry file. * - * @param {string} cwd Project root. - * @return {string} Absolute path to the entry file. - * @throws {Error} When no entry file, or more than one, exists at `cwd`. + * Walks `cwd` (root only -- no recursion) for a `.php` file whose leading bytes + * carry a `Plugin Name:` header. WordPress' own convention is exactly one such + * file per plugin, so zero or more than one is an error. When none is found, + * falls back to a theme's `style.css` with a `Theme Name:` header before giving + * up, so the release commands work for themes as well as plugins. + * + * @param {string} cwd Project root. + * @param {Object} [options] Options. + * @param {boolean} [options.pluginOnly] Skip the theme fallback and report the + * plugin-only error. Used by {@link findPluginEntry}. + * @return {{ entry: string, kind: 'plugin'|'theme' }} Entry path and what it is. + * @throws {Error} When no matching entry exists at `cwd`. */ -function findPluginEntry(cwd) { +function findEntry(cwd, { pluginOnly = false } = {}) { let entries; try { entries = fs.readdirSync(cwd, { withFileTypes: true }); @@ -60,11 +67,6 @@ function findPluginEntry(cwd) { } } - if (matches.length === 0) { - throw new Error( - `release: no plugin entry file found at "${cwd}" (expected a *.php with a "Plugin Name:" header)` - ); - } if (matches.length > 1) { throw new Error( `release: multiple plugin entry files found at "${cwd}": ${matches @@ -72,15 +74,49 @@ function findPluginEntry(cwd) { .join(', ')}` ); } - return matches[0]; + if (matches.length === 1) { + return { entry: matches[0], kind: 'plugin' }; + } + + if (pluginOnly) { + throw new Error( + `release: no plugin entry file found at "${cwd}" (expected a *.php with a "Plugin Name:" header)` + ); + } + + const styleCss = path.join(cwd, 'style.css'); + if ( + fs.existsSync(styleCss) && + /^[ \t*/#]*\s*Theme Name:\s*\S/m.test(readHeader(styleCss)) + ) { + return { entry: styleCss, kind: 'theme' }; + } + + throw new Error( + `release: no plugin or theme entry file found at "${cwd}" (expected a *.php with a "Plugin Name:" header, or a style.css with a "Theme Name:" header)` + ); +} + +/** + * The plugin-only half of {@link findEntry}, kept as the original public API. + * + * Returns the entry path as a string and never falls back to a theme, so + * callers that predate theme support keep their exact contract. + * + * @param {string} cwd Project root. + * @return {string} Absolute path to the plugin entry file. + * @throws {Error} When no plugin entry file, or more than one, exists at `cwd`. + */ +function findPluginEntry(cwd) { + return findEntry(cwd, { pluginOnly: true }).entry; } /** * Read project state for the release CLIs. * * @param {string} cwd Project root (typically `process.cwd()`). - * @return {Object} Project state with packageJson, packageJsonPath, composerJson, composerJsonPath, pluginEntry, pluginSlug and currentVersion. - * @throws {Error} When `package.json` is missing, malformed, lacks a `version`, or the plugin entry file cannot be found. + * @return {Object} Project state with packageJson, packageJsonPath, composerJson, composerJsonPath, pluginEntry, pluginSlug, kind and currentVersion. + * @throws {Error} When `package.json` is missing, malformed, lacks a `version`, or no plugin/theme entry file can be found. */ function loadContext(cwd) { const packageJsonPath = path.join(cwd, 'package.json'); @@ -117,8 +153,13 @@ function loadContext(cwd) { } } - const pluginEntry = findPluginEntry(cwd); - const pluginSlug = path.basename(pluginEntry, '.php'); + const { entry: pluginEntry, kind } = findEntry(cwd); + // A theme has no entry-file basename to key on -- WordPress identifies it by + // its directory -- so the slug comes from the folder instead. + const pluginSlug = + kind === 'theme' + ? path.basename(path.resolve(cwd)) + : path.basename(pluginEntry, '.php'); return { packageJson, @@ -127,6 +168,7 @@ function loadContext(cwd) { composerJsonPath: composerJson ? composerJsonPath : null, pluginEntry, pluginSlug, + kind, currentVersion: packageJson.version, }; } @@ -156,5 +198,6 @@ function readHeader(file) { module.exports = { loadContext, + findEntry, findPluginEntry, }; diff --git a/node-packages/wp-tooling/src/release/index.js b/node-packages/wp-tooling/src/release/index.js index ef8fafc..1a80159 100644 --- a/node-packages/wp-tooling/src/release/index.js +++ b/node-packages/wp-tooling/src/release/index.js @@ -10,12 +10,13 @@ const { bump } = require('./bump'); const { changelog } = require('./changelog'); const { zip } = require('./zip'); -const { loadContext, findPluginEntry } = require('./context'); +const { loadContext, findEntry, findPluginEntry } = require('./context'); module.exports = { bump, changelog, zip, loadContext, + findEntry, findPluginEntry, }; diff --git a/node-packages/wp-tooling/src/release/zip.js b/node-packages/wp-tooling/src/release/zip.js index 0a1fa01..40e6e2a 100644 --- a/node-packages/wp-tooling/src/release/zip.js +++ b/node-packages/wp-tooling/src/release/zip.js @@ -52,6 +52,67 @@ const DEFAULT_IGNORE_PATTERNS = [ const ALWAYS_EXCLUDE = new Set(['.git', 'dist']); +// Uncompressed payload above which a build is almost certainly shipping +// something it should not. A production plugin -- code, assets, and the +// no-dev vendor tree -- lands well under this; a vendor/ with dev requirements +// in it (PHPUnit, PHP_CodeSniffer, the WordPress stubs) lands orders of +// magnitude over. WordPress.org itself refuses uploads far smaller than this. +const SIZE_WARN_BYTES = 25 * 1024 * 1024; + +/** + * Flag a payload that is too big to be a real release. + * + * `vendor/` is deliberately not in DEFAULT_IGNORE_PATTERNS: a plugin that + * autoloads its Composer dependencies has to ship it, so excluding it by + * default would quietly produce a broken plugin. The failure worth catching is + * the opposite one -- shipping a vendor tree that still has dev requirements + * installed -- and that shows up as size, which is what this reports. + * + * @param {Array<{relPath: string, size: number}>} files Files that would ship. + * @return {string[]} Warnings, empty when the payload is a sane size. + */ +function sizeWarnings(files) { + const total = files.reduce((sum, f) => sum + (f.size || 0), 0); + if (total <= SIZE_WARN_BYTES) { + return []; + } + + // Attribute the bulk to top-level directories, which is the granularity a + // .distignore entry works at. + const byTop = new Map(); + for (const f of files) { + const top = f.relPath.split('/')[0]; + byTop.set(top, (byTop.get(top) || 0) + (f.size || 0)); + } + const biggest = [...byTop.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([name, bytes]) => `${name} (${formatBytes(bytes)})`) + .join(', '); + + return [ + `archive payload is ${formatBytes(total)} across ${files.length} files, which is far larger than a release should be. Largest: ${biggest}.`, + 'If vendor/ is listed above, run `composer install --no-dev` before building, or exclude what you do not ship in .distignore.', + ]; +} + +/** + * Render a byte count for a human. + * + * @param {number} bytes Byte count. + * @return {string} e.g. `2.3 GB`. + */ +function formatBytes(bytes) { + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} + /* -------------------------------------------------------------------------- */ /* CRC-32 */ /* -------------------------------------------------------------------------- */ @@ -344,12 +405,15 @@ function walkProject(root, patterns) { continue; } let executable = false; + let size = 0; try { - executable = (fs.statSync(absPath).mode & 0o111) !== 0; + const st = fs.statSync(absPath); + executable = (st.mode & 0o111) !== 0; + size = st.size; } catch { executable = false; } - out.push({ relPath, absPath, executable }); + out.push({ relPath, absPath, executable, size }); } } } @@ -397,7 +461,7 @@ function resolveEpoch(cwd, options) { * Build the zip plan (list of entries) without writing anything. * * @param {{ cwd?: string, epoch?: number }} options Plan options. - * @return {Object} Plan describing slug, version, epoch, files, output path. + * @return {Object} Plan describing slug, version, epoch, files, output path, warnings. */ function plan(options = {}) { const cwd = options.cwd || process.cwd(); @@ -417,6 +481,7 @@ function plan(options = {}) { files, excludedSample: [], outputPath, + warnings: sizeWarnings(files), }; } @@ -424,7 +489,7 @@ function plan(options = {}) { * Build and write the zip. * * @param {{ cwd?: string, force?: boolean, dryRun?: boolean, epoch?: number }} options Run options. - * @return {Object} Summary with outputPath, slug, version, fileCount, byteSize, dryRun. + * @return {Object} Summary with outputPath, slug, version, fileCount, byteSize, warnings, dryRun. */ function zip(options = {}) { const cwd = options.cwd || process.cwd(); @@ -458,6 +523,7 @@ function zip(options = {}) { version: p.version, fileCount: entries.length, byteSize: archive.length, + warnings: p.warnings, dryRun, }; } diff --git a/node-packages/wp-tooling/src/scaffolds/php-imports.js b/node-packages/wp-tooling/src/scaffolds/php-imports.js new file mode 100644 index 0000000..20a9aa2 --- /dev/null +++ b/node-packages/wp-tooling/src/scaffolds/php-imports.js @@ -0,0 +1,156 @@ +'use strict'; + +/** + * Sort the `use` block of rendered PHP. + * + * A Mustache template cannot emit a correctly ordered import block, because the + * order depends on a value only known at render time: the consuming project's + * own namespace. `use {{namespace}}\{{class}};` sorts before `use WP_UnitTestCase;` + * for a project namespaced `Inc\…` and after it for one namespaced `rtCamp\…`, + * and no fixed ordering in the template satisfies both. Sorting here — once the + * placeholders are filled — is the only place the right answer exists. + * + * The ordering reproduces `SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses` + * with `caseSensitive="true"`, which is what the `rtCampWP` standard enables: + * names are compared **segment by segment** (split on `\`) with a byte-wise + * comparison, and a prefix sorts before a longer name that extends it. Comparing + * whole strings instead would disagree wherever `\` (0x5C) falls between the + * bytes being compared. + * + * Deliberately conservative. This is line matching, not PHP parsing: the block + * is rewritten only when every line of it is a plain single-class import, and + * anything else — a group import, an alias, a `use function`/`use const`, a + * statement split over lines, a comment in the middle, a second block later in + * the file — leaves the source untouched rather than risking a bad edit. Every + * template in the catalogue emits the simple shape, so the bail-outs exist for + * templates that do not exist yet. + * + * Trait imports inside a class body are indented and never match, which is why + * the pattern is anchored to column zero. + */ + +// One plain class import: `use Fully\Qualified\Name;` at column zero. A single +// space after `use` and no trailing content, matching what every template emits +// and what the sniff leaves alone. +const USE_LINE_RE = + /^use ([A-Za-z_\x80-￿][A-Za-z0-9_\x80-￿]*(?:\\[A-Za-z_\x80-￿][A-Za-z0-9_\x80-￿]*)*);$/; + +// Any top-level `use` at all, including the shapes this module refuses to touch. +// Used to detect an import the block scan did not cover, which forces a bail-out. +const ANY_TOP_LEVEL_USE_RE = /^use\b/; + +/** + * Byte-wise string comparison, the semantics of PHP's `strcmp()`. + * + * Comparing JS strings with `<` uses UTF-16 code units, which agrees with a byte + * comparison for ASCII but not beyond it. Namespaces are ASCII in practice; + * going through Buffer keeps the two in step regardless. + * + * @param {string} a - Left operand. + * @param {string} b - Right operand. + * @return {number} Negative, zero or positive. + */ +function strcmp(a, b) { + return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')); +} + +/** + * Order two fully-qualified names the way the sniff does. + * + * @param {string} a - Left fully-qualified name, no leading separator. + * @param {string} b - Right fully-qualified name, no leading separator. + * @return {number} Negative, zero or positive. + */ +function compareImports(a, b) { + const aParts = a.split('\\'); + const bParts = b.split('\\'); + const shared = Math.min(aParts.length, bParts.length); + + for (let i = 0; i < shared; i++) { + const cmp = strcmp(aParts[i], bParts[i]); + if (cmp !== 0) { + return cmp; + } + } + + // Every shared segment matched, so the shorter name is the prefix of the + // longer one and sorts first. + return aParts.length - bParts.length; +} + +/** + * Return `source` with its import block sorted, or unchanged if it cannot be + * rewritten safely. + * + * @param {string} source - Rendered PHP source. + * @return {string} The source, with the import block ordered. + */ +function sortUseBlock(source) { + if (typeof source !== 'string' || !source.includes('\nuse ')) { + return source; + } + + const lines = source.split('\n'); + + const start = lines.findIndex((line) => USE_LINE_RE.test(line)); + if (start === -1) { + return source; + } + + let end = start; + while (end + 1 < lines.length && USE_LINE_RE.test(lines[end + 1])) { + end++; + } + + // A lone import is already sorted; rewriting would only risk churn. + if (end === start) { + return source; + } + + // An import outside the run means the file has a shape this module does not + // model — a second block, or one of the forms USE_LINE_RE rejects. Leave it. + for (let i = 0; i < lines.length; i++) { + if (i >= start && i <= end) { + continue; + } + if (ANY_TOP_LEVEL_USE_RE.test(lines[i])) { + return source; + } + } + + const block = lines.slice(start, end + 1); + const sorted = [...block].sort((a, b) => + compareImports(a.match(USE_LINE_RE)[1], b.match(USE_LINE_RE)[1]) + ); + + // Already in order: return the original string so callers can rely on + // identity when nothing needed doing. + if (sorted.every((line, i) => line === block[i])) { + return source; + } + + lines.splice(start, block.length, ...sorted); + return lines.join('\n'); +} + +/** + * Apply the PHP-only normalisations to one rendered file. + * + * Non-PHP destinations pass through untouched, so callers can hand every + * rendered file to this without checking first. + * + * @param {string} source - Rendered file contents. + * @param {string} destRel - Project-relative destination path. + * @return {string} Contents to write. + */ +function normalisePhp(source, destRel) { + if ( + typeof destRel !== 'string' || + !destRel.toLowerCase().endsWith('.php') + ) { + return source; + } + return sortUseBlock(source); +} + +module.exports = { compareImports, sortUseBlock, normalisePhp }; diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index c14ad10..cb1aeee 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -45,6 +45,7 @@ const { validateIndex, indexEntryToRecord, } = require('./sources'); +const { normalisePhp } = require('./php-imports'); /** * Render every value in a scripts map (npm or composer) through Mustache. @@ -400,7 +401,12 @@ class ScaffoldRegistry { } if (!dryRun) { const tpl = await loadTemplate(file.src); - const out = file.raw === true ? tpl : render(tpl, resolved); + // `raw` files ship verbatim by contract, so they are never + // normalised — only rendered output is ours to reformat. + const out = + file.raw === true + ? tpl + : normalisePhp(render(tpl, resolved), destRel); await fs.mkdir(path.dirname(destAbs), { recursive: true }); await fs.writeFile(destAbs, out, 'utf8').catch((err) => { throw new ScaffoldError( @@ -440,14 +446,30 @@ class ScaffoldRegistry { baseNestedInModules ? p.replace(/(^|\/)Modules\/Modules\//, '$1Modules/') : p; - const aiWiring = (scaffold.wiring || []).map((w) => ({ - targetFile: collapseModules( + + // Normalising collapses interior `..` but keeps a leading one, so a + // short enough path input (`--base_path=.`) still resolves outside the + // project. Drop those instead of handing the AI a file to edit above + // the project root. Warn rather than throw: this runs after the files + // are on disk, and aborting here would leave a half-applied scaffold. + const aiWiring = []; + for (const w of scaffold.wiring || []) { + const targetFile = collapseModules( path.posix.normalize(render(w.target_file, resolved)) - ), - anchor: w.anchor, - snippet: render(w.snippet_template, resolved), - description: w.description || '', - })); + ); + if (targetFile.startsWith('../') || path.isAbsolute(targetFile)) { + warnings.push( + `wiring target resolves outside the project, skipped: ${targetFile}` + ); + continue; + } + aiWiring.push({ + targetFile, + anchor: w.anchor, + snippet: render(w.snippet_template, resolved), + description: w.description || '', + }); + } // Declarative tests (lint-only entries like actionlint on the YAML the // files-loop just wrote) reuse a files[].dest. Track those so we don't @@ -467,7 +489,7 @@ class ScaffoldRegistry { ); } else if (!dryRun) { const tpl = await loadTemplate(t.src); - const out = render(tpl, resolved); + const out = normalisePhp(render(tpl, resolved), destRel); await fs.mkdir(path.dirname(destAbs), { recursive: true }); await fs.writeFile(destAbs, out, 'utf8').catch((err) => { throw new ScaffoldError( @@ -848,14 +870,98 @@ async function hydrateRemote(record, fetchOpts) { }; } +// WordPress reads a plugin/theme header out of the first 8 KB of the entry +// file; matching that bound keeps a large file cheap to scan and matches what +// core would itself see. +const HEADER_SCAN_BYTES = 8192; + +// One `Name: value` line inside a header comment, with the comment furniture +// (` * `, `#`, `//`) stripped. Header names are letters and single spaces, which +// is what keeps this from matching arbitrary colons in code. +const HEADER_LINE_RE = + /^[ \t/*#]*([A-Za-z][A-Za-z ]*[A-Za-z]):[ \t]*(\S.*?)[ \t]*$/gm; + +/** + * Read the WordPress header block of a project's entry file. + * + * Looks for a plugin first — any root-level `*.php` carrying a `Plugin Name:` + * header — then falls back to a theme's `style.css` with `Theme Name:`. Keys are + * the header name lowercased with spaces hyphenated, so `Text Domain:` is read + * as `text-domain`, which is how a manifest names it. + * + * Only the header comment is scanned: parsing stops at its closing `*\/`, so a + * `Foo: bar` in the code below can never be mistaken for a header. + * + * @param {string} cwd - Target project directory. + * @return {Promise} Header map, or null when there is no entry file. + */ +async function readEntryHeaders(cwd) { + const headerBlock = async (file) => { + let raw; + try { + const handle = await fs.open(path.join(cwd, file), 'r'); + try { + const buf = Buffer.alloc(HEADER_SCAN_BYTES); + const { bytesRead } = await handle.read( + buf, + 0, + HEADER_SCAN_BYTES, + 0 + ); + raw = buf.subarray(0, bytesRead).toString('utf8'); + } finally { + await handle.close(); + } + } catch { + return null; + } + const close = raw.indexOf('*/'); + return close === -1 ? raw : raw.slice(0, close); + }; + + const parse = (block) => { + const out = {}; + HEADER_LINE_RE.lastIndex = 0; + let m; + while ((m = HEADER_LINE_RE.exec(block)) !== null) { + out[m[1].toLowerCase().replace(/ +/g, '-')] = m[2]; + } + return out; + }; + + let entries = []; + try { + entries = await fs.readdir(cwd); + } catch { + return null; + } + + // Sorted so a directory with more than one candidate resolves the same way + // on every platform rather than following readdir order. + for (const name of entries.filter((f) => f.endsWith('.php')).sort()) { + const block = await headerBlock(name); + if (block && /^[ \t/*#]*Plugin Name:[ \t]*\S/m.test(block)) { + return parse(block); + } + } + + const style = await headerBlock('style.css'); + if (style && /^[ \t/*#]*Theme Name:[ \t]*\S/m.test(style)) { + return parse(style); + } + + return null; +} + /** * Load the project files `discover_from` can read, tolerantly. A missing or - * malformed file becomes `null` (composer/package) or `{}` (config) so that - * discovery silently falls back to each input's `default` — preserving the - * behaviour of projects that have no composer.json / .wp-tooling.json. + * malformed file becomes `null` (composer/package/plugin-header) or `{}` + * (config) so that discovery silently falls back to each input's `default` — + * preserving the behaviour of projects that have no composer.json / + * .wp-tooling.json / recognisable entry file. * * @param {string} cwd - Target project directory. - * @return {Promise} `{ 'composer.json', 'package.json', config }`. + * @return {Promise} `{ 'composer.json', 'package.json', 'plugin-header', config }`. */ async function loadDiscovery(cwd) { const readJson = async (file) => { @@ -875,6 +981,7 @@ async function loadDiscovery(cwd) { return { 'composer.json': await readJson('composer.json'), 'package.json': await readJson('package.json'), + 'plugin-header': await readEntryHeaders(cwd), config: readConfig(cwd), }; } @@ -887,15 +994,19 @@ async function loadDiscovery(cwd) { * * Supported sources: * - `composer.json:` / `package.json:` — dotted lookup of - * a string value. Special case: `autoload.psr-4` / `autoload.psr-0` reads the - * first map entry — its **namespace** (key, trailing `\\` stripped) for - * ordinary inputs, its **directory** (value) for path inputs — and grafts - * that root onto the input's `default`, keeping the default's sub-namespace - * or sub-directory. + * a string value. Special case: `autoload.psr-4` / `autoload.psr-0`, and + * their `autoload-dev` counterparts, read the first map entry — its + * **namespace** (key, trailing `\\` stripped) for ordinary inputs, its + * **directory** (value) for path inputs — and graft that root onto the + * input's `default`, keeping the default's sub-namespace or sub-directory. + * - `plugin-header:` — a value from the plugin's (or theme's) + * entry-file header, named lowercased and hyphenated: `plugin-header:text-domain` + * reads `Text Domain:`. * - `config:` — string value from `.wp-tooling.json`. * - * Unknown sources (e.g. `plugin-header:`, `input:` handled by the caller) - * return `undefined`. + * Unknown sources (`input:` is handled by the caller) return `undefined`. + * `validate` rejects any other prefix at authoring time, so an unresolvable + * source cannot reach here unnoticed. * * @param {Object} decl - The input declaration (its `key` disambiguates psr-4). * @param {Object} discovery - The object returned by {@link loadDiscovery}. @@ -915,7 +1026,7 @@ function discoverFromSource(decl, discovery) { return undefined; } const selector = spec.slice(colon + 1); - if (selector === 'autoload.psr-4' || selector === 'autoload.psr-0') { + if (/^autoload(-dev)?\.psr-[04]$/.test(selector)) { const map = getByPath(obj, selector); const firstKey = map && typeof map === 'object' && !Array.isArray(map) @@ -942,6 +1053,14 @@ function discoverFromSource(decl, discovery) { const value = getByPath(obj, selector); return typeof value === 'string' ? value : undefined; } + if (source === 'plugin-header') { + const headers = discovery['plugin-header']; + if (!headers) { + return undefined; + } + const value = headers[spec.slice(colon + 1)]; + return typeof value === 'string' && value !== '' ? value : undefined; + } if (source === 'config') { const selector = spec.slice(colon + 1); const value = getByPath(discovery.config || {}, selector); diff --git a/node-packages/wp-tooling/src/scaffolds/render.js b/node-packages/wp-tooling/src/scaffolds/render.js index 1957838..e631212 100644 --- a/node-packages/wp-tooling/src/scaffolds/render.js +++ b/node-packages/wp-tooling/src/scaffolds/render.js @@ -30,6 +30,8 @@ 'use strict'; +const path = require('path'); + class RenderError extends Error { constructor(message, details = {}) { super(message); @@ -225,6 +227,27 @@ const TRANSFORMS = { 'upper-snake-case': (s) => splitWords(s).join('_').toUpperCase(), // Escape JSON string contents; templates supply the surrounding quotes. 'json-escape': (s) => JSON.stringify(String(s)).slice(1, -1), + // Templates supply single quotes; PHP recognises only \\ and \' escapes. + 'php-escape': (s) => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"), + // Emit the complete literal, choosing the quotes Prettier would prefer. + 'js-string': (s) => { + const value = String(s); + const json = JSON.stringify(value); + if ( + (value.match(/'/g) || []).length > (value.match(/"/g) || []).length + ) { + return json; + } + return `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}'`; + }, + // Derive a PHP expression without embedding the supplied path in code. + 'php-project-root': (s) => { + const depth = path.posix + .normalize(String(s)) + .split('/') + .filter((part) => part && part !== '.').length; + return depth ? `dirname( __DIR__, ${depth} )` : '__DIR__'; + }, // POSIX-shell quote a single argument, only when it needs quoting. 'shell-escape': (s) => { const v = String(s); diff --git a/node-packages/wp-tooling/src/scaffolds/schema.js b/node-packages/wp-tooling/src/scaffolds/schema.js index b7b0758..a62819a 100644 --- a/node-packages/wp-tooling/src/scaffolds/schema.js +++ b/node-packages/wp-tooling/src/scaffolds/schema.js @@ -54,6 +54,9 @@ const ALLOWED_INPUT_TRANSFORMS = [ 'snake-case', 'upper-snake-case', 'json-escape', + 'php-escape', + 'js-string', + 'php-project-root', 'shell-escape', ]; @@ -294,6 +297,12 @@ const SCAFFOLD_SCHEMA = { module.exports = { SCAFFOLD_SCHEMA, + INPUT_ENTRY, + WIRING_ENTRY, + FILE_ENTRY, + TEST_ENTRY, + SECRET_ENTRY, + FEATURE_BLOCK, REQUIRED_FIELDS, ALLOWED_SOURCES, ALLOWED_WIZARD_STEPS, diff --git a/node-packages/wp-tooling/src/scaffolds/validate.js b/node-packages/wp-tooling/src/scaffolds/validate.js index f51772d..570b122 100644 --- a/node-packages/wp-tooling/src/scaffolds/validate.js +++ b/node-packages/wp-tooling/src/scaffolds/validate.js @@ -19,6 +19,13 @@ const fs = require('fs'); const path = require('path'); const { + SCAFFOLD_SCHEMA, + INPUT_ENTRY, + WIRING_ENTRY, + FILE_ENTRY, + TEST_ENTRY, + SECRET_ENTRY, + FEATURE_BLOCK, REQUIRED_FIELDS, ALLOWED_SOURCES, ALLOWED_WIZARD_STEPS, @@ -48,23 +55,52 @@ const CATEGORY_RE = new RegExp(CATEGORY_PATTERN); const INPUT_KEY_RE = new RegExp(INPUT_KEY_PATTERN); const SECRET_KEY_RE = new RegExp(SECRET_KEY_PATTERN); -const TOP_LEVEL_KEYS = new Set([ - 'slug', - 'category', - 'name', - 'description', - 'source', - 'wizard_step', - 'module_class', - 'files', - 'inputs', - 'wiring', - 'tests', - 'secrets', - 'scripts', - 'feature', - ...DEPENDENCY_MAPS, -]); +/** + * The keys a schema shape allows. + * + * @param {{properties: Object}} shape - A schema object with a properties map. + * @return {string[]} Allowed key names. + */ +function keysOf(shape) { + return Object.keys(shape.properties); +} + +// Allow-lists are read off the schema rather than restated here. `schema.js` +// already closes each of these shapes with `additionalProperties: false`, and a +// second copy in this file is how a key ends up accepted by the schema and +// rejected by the validator that actually runs (or the reverse). Deriving keeps +// adding a key to one edit; tests/scaffolds/schema-parity.test.js pins it. +const TOP_LEVEL_KEYS = new Set(keysOf(SCAFFOLD_SCHEMA)); +const INPUT_KEYS = keysOf(INPUT_ENTRY); +const WIRING_KEYS = keysOf(WIRING_ENTRY); +const FILE_KEYS = keysOf(FILE_ENTRY); +const TEST_KEYS = keysOf(TEST_ENTRY); +const SECRET_KEYS = keysOf(SECRET_ENTRY); + +// Source prefixes `discover_from` can actually resolve. Kept in step with the +// branches in registry.js `discoverFromSource()` (plus `input:`, which the +// caller resolves); tests/scaffolds/discover-sources.test.js pins the pairing. +const DISCOVER_SOURCES = [ + 'composer.json', + 'package.json', + 'plugin-header', + 'config', + 'input', +]; + +/** + * Does a `discover_from` spec name a source the engine can resolve? + * + * @param {string} spec - The declared spec, e.g. `composer.json:autoload.psr-4`. + * @return {boolean} True when the prefix is supported and a selector follows. + */ +function isResolvableSource(spec) { + const colon = spec.indexOf(':'); + if (colon === -1 || colon === spec.length - 1) { + return false; + } + return DISCOVER_SOURCES.includes(spec.slice(0, colon)); +} /** * Validate a parsed scaffold.json object. @@ -189,7 +225,7 @@ function validateFiles(files) { errors.push(`${fieldPath}.raw: must be a boolean`); } for (const k of Object.keys(entry)) { - if (k !== 'src' && k !== 'dest' && k !== 'raw') { + if (!FILE_KEYS.includes(k)) { errors.push(`${fieldPath}: unknown field '${k}'`); } } @@ -229,11 +265,17 @@ function validateInputs(inputs) { ) { errors.push(`${fieldPath}.description: must be a non-empty string`); } - if ( - entry.discover_from !== undefined && - typeof entry.discover_from !== 'string' - ) { - errors.push(`${fieldPath}.discover_from: must be a string`); + if (entry.discover_from !== undefined) { + if (typeof entry.discover_from !== 'string') { + errors.push(`${fieldPath}.discover_from: must be a string`); + } else if (!isResolvableSource(entry.discover_from)) { + // An unrecognised prefix used to resolve to `undefined` at run + // time and fall back to the default, silently — a manifest could + // declare discovery that never happened and nothing said so. + errors.push( + `${fieldPath}.discover_from: unknown source in "${entry.discover_from}" (supported: ${DISCOVER_SOURCES.join(', ')})` + ); + } } if (entry.default !== undefined && typeof entry.default !== 'string') { errors.push(`${fieldPath}.default: must be a string`); @@ -274,17 +316,7 @@ function validateInputs(inputs) { } } for (const k of Object.keys(entry)) { - if ( - ![ - 'key', - 'description', - 'discover_from', - 'default', - 'required', - 'transform', - 'enum', - ].includes(k) - ) { + if (!INPUT_KEYS.includes(k)) { errors.push(`${fieldPath}: unknown field '${k}'`); } } @@ -333,14 +365,7 @@ function validateWiring(wiring) { errors.push(`${fieldPath}.description: must be a string`); } for (const k of Object.keys(entry)) { - if ( - ![ - 'target_file', - 'anchor', - 'snippet_template', - 'description', - ].includes(k) - ) { + if (!WIRING_KEYS.includes(k)) { errors.push(`${fieldPath}: unknown field '${k}'`); } } @@ -379,7 +404,7 @@ function validateTests(tests) { errors.push(`${fieldPath}.command: must be a string`); } for (const k of Object.keys(entry)) { - if (!['src', 'dest', 'framework', 'command'].includes(k)) { + if (!TEST_KEYS.includes(k)) { errors.push(`${fieldPath}: unknown field '${k}'`); } } @@ -426,7 +451,7 @@ function validateSecrets(secrets) { errors.push(`${fieldPath}.required: must be a boolean`); } for (const k of Object.keys(entry)) { - if (!['key', 'scope', 'description', 'required'].includes(k)) { + if (!SECRET_KEYS.includes(k)) { errors.push(`${fieldPath}: unknown field '${k}'`); } } @@ -479,8 +504,10 @@ function validateScripts(scripts) { return errors; } -const FEATURE_LIST_KEYS = ['owned_files', 'confirm_remove', 'gitignore']; -const FEATURE_KEYS = new Set(['config_key', ...FEATURE_LIST_KEYS]); +const FEATURE_KEYS = new Set(keysOf(FEATURE_BLOCK)); +const FEATURE_LIST_KEYS = keysOf(FEATURE_BLOCK).filter( + (k) => FEATURE_BLOCK.properties[k].type === 'array' +); function validateFeature(feature) { if ( diff --git a/node-packages/wp-tooling/tests/release/bump.test.js b/node-packages/wp-tooling/tests/release/bump.test.js index f889b26..7985447 100644 --- a/node-packages/wp-tooling/tests/release/bump.test.js +++ b/node-packages/wp-tooling/tests/release/bump.test.js @@ -167,14 +167,35 @@ describe('release/bump - integration against fixture', () => { ); }); - test('exits non-zero (via thrown error) when plugin entry is missing', () => { + test('exits non-zero (via thrown error) when no entry file is present', () => { tmp = copyFixture('plugin-a'); fs.unlinkSync(path.join(tmp, 'plugin-a.php')); + // bump resolves themes as well as plugins, so a directory with neither + // reports both shapes rather than only the plugin one. expect(() => bump({ cwd: tmp, type: 'patch' })).toThrow( - /no plugin entry file/ + /no plugin or theme entry file/ ); }); + test('bumps the Version header of a theme style.css', () => { + tmp = copyFixture('plugin-a'); + fs.unlinkSync(path.join(tmp, 'plugin-a.php')); + fs.writeFileSync( + path.join(tmp, 'style.css'), + '/*\nTheme Name: Plugin A\nVersion: 1.2.3\n*/\n' + ); + + bump({ cwd: tmp, type: 'minor' }); + + expect(fs.readFileSync(path.join(tmp, 'style.css'), 'utf8')).toContain( + 'Version: 1.3.0' + ); + expect( + JSON.parse(fs.readFileSync(path.join(tmp, 'package.json'), 'utf8')) + .version + ).toBe('1.3.0'); + }); + test('rejects a malformed config.constantPrefix', () => { tmp = copyFixture('plugin-a'); const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'package.json'))); diff --git a/node-packages/wp-tooling/tests/release/zip.test.js b/node-packages/wp-tooling/tests/release/zip.test.js index e2e2a67..80a8690 100644 --- a/node-packages/wp-tooling/tests/release/zip.test.js +++ b/node-packages/wp-tooling/tests/release/zip.test.js @@ -10,6 +10,7 @@ const zlib = require('zlib'); const { zip, + plan, zipPack, crc32, dosTimeDate, @@ -326,3 +327,50 @@ describe('release/zip - integration', () => { }); /* eslint-enable no-bitwise */ + +describe('release/zip - oversized payload guard', () => { + let tmp; + afterEach(() => cleanup(tmp)); + + it('warns, naming the biggest directories, when the payload is huge', () => { + tmp = copyFixture('plugin-a'); + // A vendor/ tree with dev requirements still installed is what this + // catches in the wild; 30 MB of it stands in for 2 GB of PHPUnit. + fs.mkdirSync(path.join(tmp, 'vendor', 'phpunit'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, 'vendor', 'phpunit', 'big.bin'), + Buffer.alloc(30 * 1024 * 1024) + ); + + const result = zip({ cwd: tmp, dryRun: true }); + + expect(result.warnings).toHaveLength(2); + expect(result.warnings[0]).toMatch(/larger than a release should be/); + expect(result.warnings[0]).toContain('vendor'); + expect(result.warnings[1]).toContain('composer install --no-dev'); + }); + + it('stays quiet for a normally sized plugin', () => { + tmp = copyFixture('plugin-a'); + expect(zip({ cwd: tmp, dryRun: true }).warnings).toEqual([]); + }); + + it('still ships vendor/ rather than excluding it by default', () => { + // Excluding it would break any plugin that autoloads its Composer + // dependencies, so the guard reports size instead of dropping files. + tmp = copyFixture('plugin-a'); + fs.mkdirSync(path.join(tmp, 'vendor'), { recursive: true }); + fs.writeFileSync(path.join(tmp, 'vendor', 'autoload.php'), ' ({ + name: `x/${f.relPath}`, + data: fs.readFileSync(f.absPath), + mtimeEpoch: 0, + })) + ) + ); + expect(names).toContain('x/vendor/autoload.php'); + }); +}); 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 ec10990..6ea132d 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -5,7 +5,8 @@ * (json-escape derived input) * - wp/cli namespace + tests_namespace discovery grafts the project's * PSR-4 root onto the kind sub-namespace - * - wiring targetFile paths are normalised (no `..` segments) + * - wiring targetFile paths are normalised (no `..` segments), and a + * target that still escapes the project is dropped with a warning * - lint/i18n binds a supplied or discovered text domain, never a guessed one * - wp-api/speculation renders into the registrable layout, reuses the * registrable wiring anchor, follows a non-`includes/` PSR-4 root, and @@ -15,6 +16,11 @@ * reuse an existing wiring anchor rather than minting a new one * - 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, + * fill testVersion, minimum_wp_version, text domain and prefixes from the + * project, and wire the Composer repository rtcamp/wp-phpcs installs from; + * lint/phpcs/vip wires only the Composer plugin permission + * - setup/phpunit names the bootstrap's @package after the test namespace */ 'use strict'; @@ -49,6 +55,61 @@ beforeAll(async () => { await registry.scan(); }); +describe('quoted display text', () => { + it.each([ + ['wp/admin-page', 'page_title', { slug: 'quote', menu_title: 'Quote' }], + [ + 'wp/settings-page', + 'menu_title', + { slug: 'quote', page_title: 'Quote', option_name: 'quote' }, + ], + ['wp/cpt', 'singular', { slug: 'quote', plural: 'Quotes' }], + [ + 'wp/taxonomy', + 'plural', + { slug: 'quote', singular: 'Quote', object_type: 'post' }, + ], + ['wp/user-role', 'display_name', { slug: 'quote' }], + ['wp-api/block-bindings', 'label', { name: 'quote' }], + ])('escapes PHP display text in %s', async (id, key, inputs) => { + const cwd = makeTmpDir(); + const result = await registry.execute( + id, + { ...inputs, [key]: "Editor's \\guide" }, + { cwd } + ); + const file = result.engine.wrote.find((name) => name.endsWith('.php')); + const php = fs.readFileSync(path.join(cwd, file), 'utf8'); + expect(php).toContain("'Editor\\'s \\\\guide'"); + expect(result.engine.inputs[key]).toBe("Editor's \\guide"); + }); + + it.each(['wp/block-dynamic', 'wp/block-interactive'])( + 'preserves quotes and backslashes in block metadata for %s', + async (id) => { + const cwd = makeTmpDir(); + const title = 'Editor\'s "guide" \\ notes'; + const result = await registry.execute( + id, + { slug: 'quote', title }, + { cwd } + ); + const json = result.engine.wrote.find((name) => + name.endsWith('block.json') + ); + expect( + JSON.parse(fs.readFileSync(path.join(cwd, json), 'utf8')).title + ).toBe(title); + const edit = result.engine.wrote.find((name) => + name.endsWith('edit.js') + ); + expect(fs.readFileSync(path.join(cwd, edit), 'utf8')).toContain( + "Editor\\'s" + ); + } + ); +}); + describe('setup/psr4 wiring snippet', () => { it('renders a JSON-valid PSR-4 key for a multi-segment namespace', async () => { const r = registry; @@ -105,6 +166,22 @@ describe('wiring targetFile normalisation', () => { expect(target).toBe('includes/Modules/Cli.php'); expect(target).not.toContain('..'); }); + + it('drops a wiring target that normalises outside the project', async () => { + const r = registry; + // `wp/cli` wires into `{{base_path}}/../Modules/Cli.php`. A single + // path segment leaves nothing for the `..` to consume, so the target + // would escape the project the AI was pointed at. + const result = await r.execute( + 'wp/cli', + { name: 'export-things', base_path: '.' }, + { dryRun: true, cwd: makeTmpDir() } + ); + expect(result.ai.wiring).toEqual([]); + expect(result.warnings).toEqual([ + 'wiring target resolves outside the project, skipped: ../Modules/Cli.php', + ]); + }); }); describe('wp-api/speculation', () => { @@ -775,3 +852,137 @@ describe('utility/* package scaffolds', () => { expect(snippet).not.toContain('rtcamp_project_name_features'); }); }); + +/** + * A project whose PSR-4 root is inc/ and whose plugin header declares a text + * domain, the layout both first-party consumers use. + * + * @return {string} Path to the project. + */ +function makeIncProject() { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ + autoload: { 'psr-4': { 'Acme\\Blog\\': 'inc/' } }, + 'autoload-dev': { + 'psr-4': { 'Acme\\Blog\\Tests\\': 'tests/php/' }, + }, + }), + 'utf8' + ); + fs.writeFileSync( + path.join(target, 'acme-blog.php'), + ' { + it(`extends ${standard} and configures it from the project`, async () => { + const target = makeIncProject(); + fs.writeFileSync( + path.join(target, 'acme-blog.php'), + '`); + // PHPCompatibility aborts every file when testVersion is unset. + expect(xml).toContain(''); + expect(xml).toContain( + '' + ); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + // The package bundles PHPCS and every sniff it references. + expect(result.developer.install.composerDev).toEqual({ + 'rtcamp/wp-phpcs': '^1.0', + }); + }); + + it('wires the repository and plugin permission Composer needs to install it', async () => { + const result = await registry.execute( + id, + {}, + { dryRun: true, cwd: makeTmpDir() } + ); + const [repositories, allowPlugins] = result.ai.wiring; + + expect(result.ai.wiring.map((w) => w.targetFile)).toEqual([ + 'composer.json', + 'composer.json', + ]); + // Not on Packagist: without this entry the require cannot resolve. + expect(JSON.parse(`{${repositories.snippet}}`).repositories).toEqual([ + { + type: 'vcs', + url: 'https://github.com/rtCamp/wp-phpcs.git', + 'no-api': true, + }, + ]); + expect( + JSON.parse(`{${allowPlugins.snippet}}`).config['allow-plugins'] + ).toEqual({ 'dealerdirect/phpcodesniffer-composer-installer': true }); + }); + + it('falls back to defaults and leaves out the namespace prefix without PSR-4', async () => { + const target = makeTmpDir(); + await registry.execute(id, {}, { cwd: target }); + const xml = fs.readFileSync( + path.join(target, 'phpcs.xml.dist'), + 'utf8' + ); + + expect(xml).toContain(''); + expect(xml).toContain( + '' + ); + expect(xml).toContain(''); + expect(xml).toContain( + '\n\t\t\t' + ); + }); +}); + +describe('lint/phpcs/vip', () => { + it('allows the Composer plugin that registers the standards, without a repository', async () => { + const result = await registry.execute( + 'lint/phpcs/vip', + {}, + { dryRun: true, cwd: makeTmpDir() } + ); + + // automattic/vipwpcs resolves from Packagist, so only the plugin needs allowing. + expect(result.ai.wiring).toHaveLength(1); + expect(result.ai.wiring[0].targetFile).toBe('composer.json'); + expect(JSON.parse(`{${result.ai.wiring[0].snippet}}`)).toEqual({ + config: { + 'allow-plugins': { + 'dealerdirect/phpcodesniffer-composer-installer': true, + }, + }, + }); + }); +}); + +describe('setup/phpunit', () => { + it("names the bootstrap's package after the project's test namespace", async () => { + const target = makeIncProject(); + await registry.execute('setup/phpunit', {}, { cwd: target }); + + expect( + fs.readFileSync(path.join(target, 'tests/bootstrap.php'), 'utf8') + ).toContain(' * @package Acme\\Blog\\Tests\n'); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js b/node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js new file mode 100644 index 0000000..d98823d --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js @@ -0,0 +1,269 @@ +/** + * Tests for the `discover_from` sources added alongside the psr-4 resolvers: + * - plugin-header: -> a value from the plugin/theme entry header + * - composer.json:autoload-dev.psr-4 -> the dev autoload map, for test paths + * + * Plus the authoring-time guard that keeps the set of sources a manifest may + * declare in step with the set registry.js can actually resolve — the failure + * these replaced was a manifest declaring discovery that silently never ran. + * + * Behaviour goes through execute() (dry run), since resolveInputs is internal. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { ScaffoldRegistry } = require('../../src/scaffolds/registry'); +const { validate } = require('../../src/scaffolds/validate'); + +function makeTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-sources-')); +} + +const SCAFFOLD = { + slug: 'probe', + category: 'wp', + name: 'Probe', + description: 'Surfaces resolved inputs for assertions.', + source: 'template', + inputs: [ + { + key: 'text_domain', + description: 'i18n text domain', + discover_from: 'plugin-header:text-domain', + default: 'fallback-domain', + }, + { + key: 'tests_path', + description: 'Directory for the test file', + discover_from: 'composer.json:autoload-dev.psr-4', + default: 'tests/Cli', + }, + { key: 'name', description: 'Name', required: true }, + ], + files: [{ src: 'x.mustache', dest: '{{tests_path}}/{{name}}.php' }], + wiring: [{ target_file: 'wire.php', snippet_template: '{{text_domain}}' }], +}; + +async function run(targetDir) { + const projectDir = makeTmpDir(); + const sDir = path.join(projectDir, 'wp', 'probe'); + fs.mkdirSync(sDir, { recursive: true }); + fs.writeFileSync( + path.join(sDir, 'scaffold.json'), + JSON.stringify(SCAFFOLD), + 'utf8' + ); + const r = new ScaffoldRegistry({ projectDir }); + await r.scan(); + const result = await r.execute( + 'wp/probe', + { name: 'demo' }, + { dryRun: true, cwd: targetDir } + ); + fs.rmSync(projectDir, { recursive: true, force: true }); + return { + textDomain: result.ai.wiring[0].snippet, + testsPath: result.engine.wrote[0].replace(/\/demo\.php$/, ''), + }; +} + +describe('discover_from: plugin-header:', () => { + it('reads Text Domain from a plugin entry file', async () => { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'acme-blog.php'), + [ + ' { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'style.css'), + [ + '/*', + 'Theme Name: Elementary', + 'Text Domain: elementary-theme', + '*/', + '', + 'body { margin: 0; }', + '', + ].join('\n') + ); + expect((await run(target)).textDomain).toBe('elementary-theme'); + }); + + it('prefers a plugin entry over a style.css in the same directory', async () => { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'acme-blog.php'), + ' { + // A `Text Domain:` further down the file is code, not a header, and must + // not win over the real one. + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'acme-blog.php'), + [ + ' { + expect((await run(makeTmpDir())).textDomain).toBe('fallback-domain'); + }); + + it('falls back to the default when the header is absent', async () => { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'acme-blog.php'), + ' { + it('grafts the dev autoload directory onto the default', async () => { + // The bug this closes: tests were written to the manifest default + // (`tests/Cli`) while PHPUnit only ran `tests/php/`, so a generated test + // was never discovered. + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ + autoload: { 'psr-4': { 'Acme\\Blog\\': 'inc/' } }, + 'autoload-dev': { + 'psr-4': { 'Acme\\Blog\\Tests\\': 'tests/php/' }, + }, + }) + ); + expect((await run(target)).testsPath).toBe('tests/php/Cli'); + }); + + it('falls back to the default when there is no autoload-dev map', async () => { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ + autoload: { 'psr-4': { 'Acme\\Blog\\': 'inc/' } }, + }) + ); + expect((await run(target)).testsPath).toBe('tests/Cli'); + }); + + it('does not read the production map for a dev selector', async () => { + // `autoload.psr-4` maps to `inc/`; resolving the dev selector against it + // would put tests inside the source tree. + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'composer.json'), + JSON.stringify({ + autoload: { 'psr-4': { 'Acme\\Blog\\': 'inc/' } }, + }) + ); + expect((await run(target)).testsPath).not.toContain('inc/'); + }); +}); + +describe('validate rejects a source the engine cannot resolve', () => { + const manifest = (discoverFrom) => ({ + slug: 'x', + name: 'X', + description: 'd', + source: 'template', + files: [{ src: 't', dest: 'd' }], + inputs: [{ key: 'a', description: 'd', discover_from: discoverFrom }], + }); + const errorsFor = (spec) => + validate(manifest(spec)).filter((e) => e.includes('discover_from')); + + it.each([ + 'composer.json:autoload.psr-4', + 'composer.json:autoload-dev.psr-4', + 'package.json:name', + 'plugin-header:text-domain', + 'config:cssDir', + 'input:name', + ])('accepts %s', (spec) => { + expect(errorsFor(spec)).toEqual([]); + }); + + it.each([ + ['an unknown prefix', 'plugin-headers:text-domain'], + ['a source with no selector', 'plugin-header'], + ['an empty selector', 'composer.json:'], + ['a bare word', 'autoload.psr-4'], + ])('rejects %s', (_label, spec) => { + expect(errorsFor(spec)).toHaveLength(1); + expect(errorsFor(spec)[0]).toMatch(/unknown source/); + }); + + it('names the supported sources so the error is actionable', () => { + expect(errorsFor('nope:thing')[0]).toContain('plugin-header'); + }); +}); + +describe('the bundled catalogue only declares resolvable sources', () => { + it('has no manifest that validate would reject', () => { + // The regression that motivated the guard: every wp/* manifest declared + // `plugin-header:text-domain` while nothing implemented it, so all seven + // silently shipped `my-plugin` as the text domain. + const root = path.join(__dirname, '..', '..', 'scaffolds'); + const manifests = []; + (function walk(dir) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + walk(full); + } else if (e.name === 'scaffold.json') { + manifests.push(full); + } + } + })(root); + + expect(manifests.length).toBeGreaterThan(0); + + const bad = []; + for (const file of manifests) { + const errs = validate( + JSON.parse(fs.readFileSync(file, 'utf8')) + ).filter((e) => e.includes('discover_from')); + if (errs.length) { + bad.push(`${path.relative(root, file)}: ${errs.join('; ')}`); + } + } + expect(bad).toEqual([]); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/php-imports.test.js b/node-packages/wp-tooling/tests/scaffolds/php-imports.test.js new file mode 100644 index 0000000..3a92f95 --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/php-imports.test.js @@ -0,0 +1,199 @@ +'use strict'; + +const { + compareImports, + sortUseBlock, + normalisePhp, +} = require('../../src/scaffolds/php-imports'); + +/** + * Build a small PHP file around an import block. + * + * @param {string[]} imports - Fully-qualified names to import. + * @return {string} PHP source. + */ +function phpWith(imports) { + return [ + ' `use ${name};`), + '', + 'final class Thing {}', + '', + ].join('\n'); +} + +/** + * Read back the imported names, in file order. + * + * @param {string} source - PHP source. + * @return {string[]} Names. + */ +function importsOf(source) { + return source + .split('\n') + .filter((line) => line.startsWith('use ')) + .map((line) => line.slice(4, -1)); +} + +describe('compareImports', () => { + it('compares segment by segment, not as whole strings', () => { + // Whole-string strcmp would put `Acme\Blog` after `Acme_Blog`, because + // `\` (0x5C) sorts before `_` (0x5F). Segment-wise, the first segments + // `Acme` and `Acme_Blog` decide it, giving the same answer here — the + // point is that the segment walk is what runs. + expect(compareImports('Acme\\Blog', 'Acme_Blog')).toBeLessThan(0); + }); + + it('sorts a prefix before a name that extends it', () => { + expect(compareImports('Acme', 'Acme\\Blog')).toBeLessThan(0); + expect(compareImports('Acme\\Blog', 'Acme')).toBeGreaterThan(0); + }); + + it('is case sensitive, so uppercase sorts before lowercase', () => { + // `W` (0x57) before `r` (0x72): this is the ordering that makes a + // `rtCamp\…` project need a different import order from an `Inc\…` one. + expect(compareImports('WP_UnitTestCase', 'rtCamp\\Thing')).toBeLessThan( + 0 + ); + expect( + compareImports('PHPUnit\\Framework\\TestCase', 'Acme\\Thing') + ).toBeGreaterThan(0); + }); + + it('reports equal names as equal', () => { + expect(compareImports('Acme\\Blog', 'Acme\\Blog')).toBe(0); + }); +}); + +describe('sortUseBlock', () => { + it('orders an out-of-order block', () => { + const source = phpWith([ + 'rtCamp\\Theme\\Elementary\\Cli\\Sync', + 'WP_UnitTestCase', + ]); + expect(importsOf(sortUseBlock(source))).toEqual([ + 'WP_UnitTestCase', + 'rtCamp\\Theme\\Elementary\\Cli\\Sync', + ]); + }); + + it('leaves an already-ordered block byte-identical', () => { + const source = phpWith(['WP_UnitTestCase', 'rtCamp\\Theme\\Thing']); + expect(sortUseBlock(source)).toBe(source); + }); + + it('keeps a project namespace that genuinely sorts first in place', () => { + // The mirror of the case above: `Inc\…` sorts before `PHPUnit\…`, so an + // order correct for this project must survive untouched. + const source = phpWith([ + 'Inc\\Cli\\Sync', + 'PHPUnit\\Framework\\TestCase', + ]); + expect(sortUseBlock(source)).toBe(source); + }); + + it('touches nothing but the import lines', () => { + const source = phpWith(['rtCamp\\Thing', 'WP_UnitTestCase']); + const sorted = sortUseBlock(source); + const strip = (s) => + s + .split('\n') + .filter((line) => !line.startsWith('use ')) + .join('\n'); + expect(strip(sorted)).toBe(strip(source)); + }); + + it('leaves a single import alone', () => { + const source = phpWith(['rtCamp\\Thing']); + expect(sortUseBlock(source)).toBe(source); + }); + + it('leaves a file with no imports alone', () => { + const source = ' { + it.each([ + ['a group import', 'use Acme\\{One, Two};'], + ['an alias', 'use Acme\\One as Two;'], + ['a function import', 'use function Acme\\helper;'], + ['a constant import', 'use const Acme\\LIMIT;'], + ['a statement split over lines', 'use Acme\\One,'], + ])('%s elsewhere in the file', (_label, oddity) => { + const source = phpWith([ + 'rtCamp\\Thing', + 'WP_UnitTestCase', + ]).replace( + 'final class Thing {}', + `${oddity}\n\nfinal class Thing {}` + ); + expect(sortUseBlock(source)).toBe(source); + }); + + it('a comment interleaved in the block splits the run and is left alone', () => { + const source = [ + ' { + const source = [ + ' { + it('sorts a .php destination', () => { + const source = phpWith(['rtCamp\\Thing', 'WP_UnitTestCase']); + expect(importsOf(normalisePhp(source, 'inc/Thing.php'))).toEqual([ + 'WP_UnitTestCase', + 'rtCamp\\Thing', + ]); + }); + + it('is case insensitive about the extension', () => { + const source = phpWith(['rtCamp\\Thing', 'WP_UnitTestCase']); + expect(normalisePhp(source, 'inc/Thing.PHP')).not.toBe(source); + }); + + it.each(['src/js/module.js', 'block.json', 'README.md', 'noext'])( + 'passes %s through untouched', + (dest) => { + const source = phpWith(['rtCamp\\Thing', 'WP_UnitTestCase']); + expect(normalisePhp(source, dest)).toBe(source); + } + ); + + it('passes through a non-string destination', () => { + const source = phpWith(['rtCamp\\Thing', 'WP_UnitTestCase']); + expect(normalisePhp(source, undefined)).toBe(source); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/render.test.js b/node-packages/wp-tooling/tests/scaffolds/render.test.js index 012b130..c9ad4ee 100644 --- a/node-packages/wp-tooling/tests/scaffolds/render.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/render.test.js @@ -157,6 +157,28 @@ describe('render — sections', () => { }); describe('applyTransform', () => { + it('escapes PHP single-quoted text without changing double quotes', () => { + expect(applyTransform('Editor\'s "guide" \\ notes', 'php-escape')).toBe( + 'Editor\\\'s "guide" \\\\ notes' + ); + }); + + it('escapes JavaScript apostrophes and control characters', () => { + expect(applyTransform("Editor's\n\\notes", 'js-string')).toBe( + '"Editor\'s\\n\\\\notes"' + ); + }); + + it.each([ + ['.', '__DIR__'], + ['./', '__DIR__'], + ['inc/Blocks', 'dirname( __DIR__, 2 )'], + ['inc/Modules/Blocks', 'dirname( __DIR__, 3 )'], + ['./inc/Modules/../Blocks/', 'dirname( __DIR__, 2 )'], + ])('resolves the project root depth for %s', (directory, expected) => { + expect(applyTransform(directory, 'php-project-root')).toBe(expected); + }); + it('json-escape round-trips quotes, backslashes and control characters', () => { const value = '"quoted"\\path\n\t\r\u0000'; expect(JSON.parse(`"${applyTransform(value, 'json-escape')}"`)).toBe( diff --git a/node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js b/node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js new file mode 100644 index 0000000..8427b40 --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/schema-parity.test.js @@ -0,0 +1,147 @@ +/** + * Tests that src/scaffolds/validate.js and src/scaffolds/schema.js agree on + * which keys a manifest may carry. + * + * schema.js closes every manifest shape with `additionalProperties: false`, + * but validate.js is the validator that actually runs. The two used to keep + * independent copies of the same key lists, so a new key could be accepted by + * one and rejected by the other -- adding `enum` needed both edits, and + * nothing would have caught doing only one. validate.js now derives its + * allow-lists from the schema; these tests pin that from the outside, so the + * derivation cannot be quietly replaced by a fresh set of literals. + */ + +'use strict'; + +const { validate } = require('../../src/scaffolds/validate'); +const { + SCAFFOLD_SCHEMA, + INPUT_ENTRY, + WIRING_ENTRY, + FILE_ENTRY, + TEST_ENTRY, + SECRET_ENTRY, + FEATURE_BLOCK, +} = require('../../src/scaffolds/schema'); + +const SHAPES = { + 'scaffold.json root': SCAFFOLD_SCHEMA, + 'files[]': FILE_ENTRY, + 'inputs[]': INPUT_ENTRY, + 'wiring[]': WIRING_ENTRY, + 'tests[]': TEST_ENTRY, + 'secrets[]': SECRET_ENTRY, + feature: FEATURE_BLOCK, +}; + +/** + * A manifest exercising every key the schema declares, in every position. + * + * @return {Object} A scaffold.json object that must validate clean. + */ +const everyKey = () => ({ + slug: 'cli', + category: 'wp', + name: 'CLI', + description: 'Adds a CLI command.', + source: 'template', + wizard_step: null, + module_class: 'Inc\\Cli\\Command', + files: [{ src: 'a.mustache', dest: 'a.php', raw: false }], + inputs: [ + { + key: 'mode', + description: 'Mode.', + discover_from: 'input:name', + default: 'auto', + required: false, + transform: 'kebab-case', + enum: ['auto', 'manual'], + }, + ], + wiring: [ + { + target_file: 'includes/Plugin.php', + anchor: '// scaffold:cli-commands', + snippet_template: '{{class}}::class,', + description: 'Register it.', + }, + ], + tests: [ + { + src: 't.mustache', + dest: 'tests/ATest.php', + framework: 'phpunit', + command: 'npm run test:php', + }, + ], + secrets: [ + { + key: 'API_TOKEN', + scope: 'github-actions', + description: 'Token.', + required: true, + }, + ], + scripts: { npm: { lint: 'eslint .' }, composer: { lint: 'phpcs' } }, + feature: { + config_key: 'cli', + owned_files: ['includes/Cli/Command.php'], + confirm_remove: ['tests/ATest.php'], + gitignore: ['/build'], + }, + npm_dependencies: { a: '^1.0.0' }, + npm_dev_dependencies: { b: '^1.0.0' }, + composer_dependencies: { 'a/b': '^1.0' }, + composer_dev_dependencies: { 'c/d': '^1.0' }, + composer_suggest: { 'e/f': 'why' }, +}); + +describe('schema/validator key parity', () => { + it.each(Object.entries(SHAPES))( + '%s is a closed shape, so deriving its keys is meaningful', + (_name, shape) => { + expect(shape.additionalProperties).toBe(false); + } + ); + + it('accepts a manifest using every key the schema declares', () => { + expect(validate(everyKey())).toEqual([]); + }); + + it('covers every schema-declared root key in that manifest', () => { + // Guards the test above from rotting: a key added to the schema but + // not to the fixture would otherwise leave the parity check vacuous. + expect(Object.keys(everyKey()).sort()).toEqual( + Object.keys(SCAFFOLD_SCHEMA.properties).sort() + ); + }); +}); + +describe('unknown keys are rejected in every position', () => { + it('rejects an unknown root key', () => { + expect(validate({ ...everyKey(), bogus: 1 })).toEqual([ + "unknown top-level field 'bogus'", + ]); + }); + + it.each([ + ['files', 'files[0]'], + ['inputs', 'inputs[0]'], + ['wiring', 'wiring[0]'], + ['tests', 'tests[0]'], + ['secrets', 'secrets[0]'], + ])('rejects an unknown key in %s', (block, fieldPath) => { + const manifest = everyKey(); + manifest[block][0].bogus = 1; + expect(validate(manifest)).toEqual([ + `${fieldPath}: unknown field 'bogus'`, + ]); + }); + + it('rejects an unknown key in feature', () => { + const manifest = everyKey(); + manifest.feature.bogus = 1; + expect(validate(manifest)).toEqual(["feature: unknown field 'bogus'"]); + }); +}); From 18662418a2df0df67e35dae90f861c70672c583c Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 1 Oct 2026 16:28:11 +0530 Subject: [PATCH 02/15] feat(scaffolds)!: target rtcamp/wp-primitives ^2.0 wp-framework is renamed to wp-primitives in 2.0.0 (rtCamp/wp-framework#98). - composer deps and WPPrimitives namespace across templates, tests, snippets - docs/wp-framework-contract.md renamed to docs/wp-primitives-contract.md - block JS keeps WordPress spacing; docblock and CHANGELOG follow-ups --- node-packages/wp-tooling/AGENTS.md | 2 +- node-packages/wp-tooling/CHANGELOG.md | 9 +++++++-- .../wp-tooling/docs/authoring-scaffolds.md | 8 ++++---- node-packages/wp-tooling/docs/examples.md | 6 +++--- ...k-contract.md => wp-primitives-contract.md} | 18 +++++++++--------- .../claude-skills/templates/setup-SKILL.md | 2 +- .../scaffolds/utility/cache/scaffold.json | 10 +++++----- .../utility/feature-selector/scaffold.json | 10 +++++----- .../scaffolds/utility/logger/scaffold.json | 10 +++++----- .../scaffolds/utility/timer/scaffold.json | 10 +++++----- .../scaffolds/utility/transients/scaffold.json | 10 +++++----- .../wp-api/block-bindings/scaffold.json | 2 +- .../templates/block-bindings.php.mustache | 4 ++-- .../block-bindings/templates/test.php.mustache | 4 ++-- .../wp-api/script-module/scaffold.json | 2 +- .../templates/script-module.php.mustache | 14 +++++++------- .../script-module/templates/test.php.mustache | 13 ++++++------- .../scaffolds/wp-api/speculation/scaffold.json | 2 +- .../templates/speculation.php.mustache | 4 ++-- .../speculation/templates/test.php.mustache | 4 ++-- .../scaffolds/wp/admin-page/scaffold.json | 4 ++-- .../templates/admin-page.php.mustache | 4 ++-- .../wp/admin-page/templates/test.php.mustache | 2 +- .../scaffolds/wp/block-dynamic/scaffold.json | 4 ++-- .../templates/block-class.php.mustache | 7 +++---- .../block-dynamic/templates/test.php.mustache | 2 +- .../wp/block-interactive/scaffold.json | 2 +- .../templates/block-registrar.php.mustache | 4 ++-- .../templates/test.php.mustache | 4 ++-- .../wp-tooling/scaffolds/wp/cli/scaffold.json | 4 ++-- .../wp/cli/templates/command.php.mustache | 4 ++-- .../wp/cli/templates/test.php.mustache | 4 ++-- .../wp-tooling/scaffolds/wp/cpt/scaffold.json | 4 ++-- .../wp/cpt/templates/post-type.php.mustache | 4 ++-- .../wp/cpt/templates/test.php.mustache | 2 +- .../wp-tooling/scaffolds/wp/cron/scaffold.json | 4 ++-- .../wp/cron/templates/cron.php.mustache | 4 ++-- .../wp/cron/templates/test.php.mustache | 2 +- .../scaffolds/wp/module/scaffold.json | 2 +- .../wp/module/templates/module.php.mustache | 4 ++-- .../wp/module/templates/test.php.mustache | 2 +- .../scaffolds/wp/registrable/scaffold.json | 4 ++-- .../templates/registrable.php.mustache | 4 ++-- .../wp/registrable/templates/test.php.mustache | 4 ++-- .../wp-tooling/scaffolds/wp/rest/scaffold.json | 4 ++-- .../wp/rest/templates/controller.php.mustache | 4 ++-- .../wp/rest/templates/test.php.mustache | 2 +- .../scaffolds/wp/settings-page/scaffold.json | 4 ++-- .../templates/settings-page.php.mustache | 4 ++-- .../settings-page/templates/test.php.mustache | 2 +- .../scaffolds/wp/shortcode/scaffold.json | 4 ++-- .../shortcode/templates/shortcode.php.mustache | 4 ++-- .../wp/shortcode/templates/test.php.mustache | 2 +- .../scaffolds/wp/taxonomy/scaffold.json | 4 ++-- .../taxonomy/templates/taxonomy.php.mustache | 4 ++-- .../wp/taxonomy/templates/test.php.mustache | 2 +- .../scaffolds/wp/user-role/scaffold.json | 4 ++-- .../wp/user-role/templates/test.php.mustache | 2 +- .../user-role/templates/user-role.php.mustache | 4 ++-- node-packages/wp-tooling/skills/setup/SKILL.md | 2 +- .../tests/scaffolds/bundled-manifests.test.js | 8 ++++---- 61 files changed, 146 insertions(+), 143 deletions(-) rename node-packages/wp-tooling/docs/{wp-framework-contract.md => wp-primitives-contract.md} (91%) diff --git a/node-packages/wp-tooling/AGENTS.md b/node-packages/wp-tooling/AGENTS.md index c955f0d..ee0d1fc 100644 --- a/node-packages/wp-tooling/AGENTS.md +++ b/node-packages/wp-tooling/AGENTS.md @@ -43,7 +43,7 @@ src/ bin/wp-tooling.js thin shim requiring ../src/cli/index.js scaffolds/ top-level categories: ci/, lint/, setup/, wp/ docs/ ai-orchestration.md, authoring-scaffolds.md, editor-setup.md, - examples.md, wp-framework-contract.md + examples.md, wp-primitives-contract.md skills/ README.md + scaffold/ + setup/ + devtools-setup/ (each with SKILL.md + evals/); *-workspace/ dirs are gitignored eval scratch, not shipped content tests/ mirrors src/'s 8 dirs, plus fixtures/ and a skills/ test dir (10 total) diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 846d44e..ae5b996 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `wp-tooling coverage-gate` subcommand (`src/ci/coverage-gate/` — `diff`, `reports`, `gate`, `render`, `cli`; `computeGate`, `parseClover`, `parseLcov`, `parseDiffHunks`, `GateError`, `UNMEASURED_POLICIES`, `DEFAULT_UNMEASURED_POLICY` exported from `@rtcamp/wp-tooling/ci`). Diffs committed changes only (`...HEAD`, `--unified=0 --find-renames --diff-filter=AMR --relative`) from `--working-dir`, intersects the added/modified lines with a Clover (`--coverage-clover`) or LCOV report, and exits 1 when the covered share of changed executable lines is below `--threshold` (default 80; exactly at the threshold passes — compared in integer hundredths of a percent, so 8 of 10 at 80 and 147 of 1500 at 9.8 pass). Only Clover `stmt`/`cond` lines count — `type="method"` signature lines don't. Report paths are matched to repo paths by path-segment suffix, so wp-env container paths and absolute LCOV paths resolve. Changed source files missing from the report are listed as "not measured" and never counted as covered; `--unmeasured warn|fail|ignore` (default `warn`) decides whether they annotate a warning, fail the gate, or are only listed. Test and dependency paths are skipped (`--exclude ` replaces the pattern, `--no-exclude` disables it); other out-of-scope files are counted in the summary. Base resolves from `--base`, then the `pull_request`/`merge_group` base SHA in `$GITHUB_EVENT_PATH`, then `origin/$GITHUB_BASE_REF`; with none (push events) the gate is skipped with a notice. A missing or empty report, or a missing base commit, exits 2 rather than passing. `--output github` writes `changed-lines`, `covered-lines`, `percent`, `unmeasured-files`, `passed` step outputs, a `$GITHUB_STEP_SUMMARY` table with per-file missed line ranges, and per-file annotations. An empty flag value counts as not given, so workflows can pass unset inputs through. `--soft-fail` exits 0 and downgrades failure annotations to warnings; `--dry-run` previews the files and annotations without emitting workflow commands. `--threshold` accepts a plain decimal from 0 to 100 with at most two decimals. For rtCamp/wp-shared-workflows#36. - `lint/i18n` scaffold — a standalone `phpcs.i18n.xml.dist` running only `WordPress.WP.I18n` against the project's text domain, plus a `lint:i18n` composer script. Standalone because the `text_domain` property is project-specific and the engine never edits an existing `phpcs.xml.dist`. The `text_domain` input is `required` (discovered from `.wp-tooling.json` `textDomain` when present) rather than defaulted, because the sniff silently loses its `MissingArgDomain` / `TextDomainMismatch` checks when the property is wrong or unset. The rendered ruleset deliberately omits `default` from the allowed domains — listing it downgrades a missing domain argument from the `MissingArgDomain` error to the `MissingArgDomainDefault` warning — with a commented opt-in for projects that intentionally reuse core strings. Companion to the i18n lens skill in `rtcamp/wp-devtools`. -- `utility/*` package scaffolds — `utility/cache`, `utility/transients`, `utility/logger`, `utility/timer` and `utility/feature-selector` wire the `rtcamp/wp-framework` `Utils\*` classes with no template files. Each is `source: "package"`: zero files written, `rtcamp/wp-framework: ^1.0` reported under `developer.install.composer`, and one `ai.wiring` accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. `context_slug` is discovered from `composer.json:name` and normalised to snake_case (`rtcamp/acme-blog` → `rtcamp_acme_blog`); pass `--context_slug` to override. Nothing is installed and nothing is wired automatically. These utilities are neither `Registrable` nor `Shareable`, so the wiring targets the consumer's static helper rather than a module or `Main::CLASSES` — the `Loader` would otherwise construct them with a missing or empty constructor argument. +- `utility/*` package scaffolds — `utility/cache`, `utility/transients`, `utility/logger`, `utility/timer` and `utility/feature-selector` wire the `rtcamp/wp-primitives` `Utils\*` classes with no template files. Each is `source: "package"`: zero files written, `rtcamp/wp-primitives: ^2.0` reported under `developer.install.composer`, and one `ai.wiring` accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. `context_slug` is discovered from `composer.json:name` and normalised to snake_case (`rtcamp/acme-blog` → `rtcamp_acme_blog`); pass `--context_slug` to override. Nothing is installed and nothing is wired automatically. These utilities are neither `Registrable` nor `Shareable`, so the wiring targets the consumer's static helper rather than a module or `Main::CLASSES` — the `Loader` would otherwise construct them with a missing or empty constructor argument. - `wp-tooling a11y` subcommand + `@rtcamp/wp-tooling/a11y` library (`runA11y()` core, `runCli()` CLI adapter). Runs the consumer-installed `pa11y-ci` — resolved from the nearest local or hoisted `node_modules/pa11y-ci/package.json` and launched through its `bin` entry with Node; no shim execution, npx fallback, or network install — against the URLs in the project's pa11y config (`.pa11yci.json` by default, `--config ` to point elsewhere) and normalises the JSON into a stable report: `summary` counts plus per-URL `violations` carrying `id`, parsed `wcagCriterion`, `impact`, `runner`, `message`, `selector`, `context`, and grep-ready `domHints` (`tagName`, `classList`, `idAttr`, `attrs`). A URL that fails to load is a `scanError` counted in `summary.failedUrls`, never a violation. Exit codes: 0 clean · 1 run failure or unreachable URL · 2 usage/binary missing · 3 violations found. Supports `--output text|json` and `--dry-run`. The `setup/pa11y` scaffold's dependency pin is corrected to the published `pa11y-ci ^4.1.1`, and its config template now runs both engines (`"runners": ["axe", "htmlcs"]`) and scans project-owned URLs — the front page plus configurable page paths (`sample_page`, `search_page`, optional `extra_page`, each appended to `base_url`) — instead of `wp-admin`/`wp-login` (unauthenticated admin scans only ever audit the core-owned login chrome). - `accessibility` Claude Code skill (`skills/accessibility/`) — a find → fix → re-check lens over `wp-tooling a11y`: triages violations by WCAG criterion and impact, maps each one to the theme/plugin source that rendered it via the report's `domHints`, proposes minimal fixes with consent, and re-verifies until clean; core/third-party markup is classified as upstream and reported, never patched. Installed into consumers by `setup/claude-skills` alongside the `scaffold` and `setup` skills. - `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (launched through its package `bin` entry with Node via the shared `src/a11y/resolve-bin.js` resolver, like `pa11y-ci` — no `.bin` shim or npx fallback; `--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits; it navigates in its own browser, so it still runs (and records or degrades on its own) after a puppeteer navigation failure. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD`, or `EBADCONFIG` for a known field with the wrong type or value, e.g. `"enabled": "false"` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. `engines.node` is `>=22.19` (Lighthouse 13's floor), with a matching root `.nvmrc`. @@ -31,6 +31,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `automattic/vipwpcs` as `lint/phpcs/vip`'s dev dependency, with `allow-plugins` wiring for `dealerdirect/phpcodesniffer-composer-installer`, which WPCS and VIPCS depend on: without it a non-interactive `composer require` exits 1 and no standard is registered. The `setup` skill's install command and `docs/examples.md` use the same package. - VIP project detection in the `setup` skill, matching `WordPressVIPMinimum`, `WordPress-VIP-Go`, `automattic/vipwpcs`, `VIP_GO_APP_ENVIRONMENT` or `VIP_GO_ENV` across `*.php`, `*.xml`, `*.dist` (including `phpcs.xml.dist`), `*.json` and `*.yml`. - `tests_namespace` input for `setup/phpunit`, which names `tests/bootstrap.php`'s `@package` after the project's PSR-4 test namespace so the bootstrap passes `WordPress-Docs`. +- `php-escape`, `js-string` and `php-project-root` input transforms. `php-escape` and `js-string` embed display text in PHP and JavaScript string literals, and `php-project-root` turns a project-relative directory into a PHP root expression (`inc/Blocks` → `dirname( __DIR__, 2 )`). Derived inputs use them via `discover_from: input:`. + +### Changed + +- **BREAKING:** PHP scaffolds now target `rtcamp/wp-primitives` `^2.0` and the `rtCamp\WPPrimitives\` namespace. Projects on `rtcamp/wp-framework` `^1.0` must [migrate to wp-primitives 2.0](https://github.com/rtCamp/wp-primitives/blob/main/docs/upgrading.md) before generating new classes. ### Fixed @@ -66,7 +71,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The scaffold skill now states outright that it must not wire a class into a spot a later `wp-tooling init` deletes. rtCamp skeletons wrap optional capabilities in `// wp:example:` … `:end` regions and `init` removes a dropped region body and all, but the insertion rule was "after the last sampled occurrence" — which in `Main::CLASSES` is the last line *inside* such a region. `SKILL.md` and `docs/ai-orchestration.md` now refuse that placement at every fall-through level, anchors included. Hardening rather than a repair: on the eval fixtures the previous wording already landed the snippet outside the region, so this makes a safe behaviour a stated rule instead of an inference. - `validate.js` derives its manifest key allow-lists from `schema.js` instead of restating them. The two carried independent copies of the same seven lists, so a key could be accepted by the schema and rejected by the validator that actually runs; adding `enum` needed both edits and nothing would have caught doing only one. - A `wiring` entry whose `target_file` still resolves outside the project after normalisation is dropped with a warning rather than handed to the AI to edit. `path.posix.normalize` collapses interior `..` but keeps a leading one, so a single-segment path input reached above the project root. -- `docs/wp-framework-contract.md` documented `get_classes()` as `public` and described the wiring snippet as going *above* the module's marker comment, contradicting both the skill and `docs/ai-orchestration.md`. +- `docs/wp-primitives-contract.md` documented `get_classes()` as `public` and described the wiring snippet as going *above* the module's marker comment, contradicting both the skill and `docs/ai-orchestration.md`. ## [1.0.0] - 2026-07-30 diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index 25d4fbe..04b48fa 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -90,7 +90,7 @@ class {{class}} { } ```mustache {{#with_logging}}use {{namespace}}\Services\Logger; -{{/with_logging}}use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +{{/with_logging}}use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; ``` `{{#key}}...{{/key}}` renders the inner block when `vars[key]` is truthy. Truthy means a non-empty string that is not one of `"false"`, `"no"`, `"0"` (case-insensitive). @@ -232,7 +232,7 @@ declares `files: []`, the `composer_dependencies` entry that provides the class, snippet that constructs it. `execute()` reports `scaffold.kind: "package"` and writes nothing. - `module_class`: the fully qualified PHP class the scaffold wires up (e.g. - `rtCamp\\WPFramework\\Utils\\Cache` — doubled backslashes, since this is JSON). Manifest metadata + `rtCamp\\WPPrimitives\\Utils\\Cache` — doubled backslashes, since this is JSON). Manifest metadata documenting which vendor class the `wiring[]` snippet constructs. Optional, but set it on every package scaffold. @@ -431,6 +431,6 @@ Copy the closest match, rename, adjust. Most scaffolds are 20-50 lines of JSON p --- -## When to involve `wp-framework` +## When to involve `wp-primitives` -If your new scaffold extends or uses a class from `rtcamp/wp-framework` that does not yet exist, add the contract to [docs/wp-framework-contract.md](wp-framework-contract.md) and open an issue on `wp-framework`. Do not ship a scaffold that references an unreleased class. It will break for consumers running `composer install`. +If your new scaffold extends or uses a class from `rtcamp/wp-primitives` that does not yet exist, add the contract to [docs/wp-primitives-contract.md](wp-primitives-contract.md) and open an issue on `rtCamp/wp-primitives`. Do not ship a scaffold that references an unreleased class. It will break for consumers running `composer install`. diff --git a/node-packages/wp-tooling/docs/examples.md b/node-packages/wp-tooling/docs/examples.md index 81467a6..f75969d 100644 --- a/node-packages/wp-tooling/docs/examples.md +++ b/node-packages/wp-tooling/docs/examples.md @@ -95,7 +95,7 @@ Developer actions: composer dump-autoload --optimize - composer require rtcamp/wp-framework:^1.0 + composer require rtcamp/wp-primitives:^2.0 composer require --dev rtcamp/wp-phpcs:^1.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^0.1.0 @@ -359,7 +359,7 @@ Tests: > 2. wp/taxonomy → includes/Taxonomies/Category.php > 3. wp/rest → includes/Rest/ProductController.php > -> All three pull `rtcamp/wp-framework:^1.0`. Each class extends the matching abstract from the framework and is wired through the consumer's module. +> All three pull `rtcamp/wp-primitives:^2.0`. Each class extends the matching abstract from the framework and is wired through the consumer's module. > > Confirm? @@ -396,7 +396,7 @@ Files written: 6 (3 production + 3 tests). Wiring applied: 3 entries in includes/Plugin.php. Developer actions: - composer require rtcamp/wp-framework:^1.0 + composer require rtcamp/wp-primitives:^2.0 composer dump-autoload --optimize ``` diff --git a/node-packages/wp-tooling/docs/wp-framework-contract.md b/node-packages/wp-tooling/docs/wp-primitives-contract.md similarity index 91% rename from node-packages/wp-tooling/docs/wp-framework-contract.md rename to node-packages/wp-tooling/docs/wp-primitives-contract.md index b6c18f5..37bf5c0 100644 --- a/node-packages/wp-tooling/docs/wp-framework-contract.md +++ b/node-packages/wp-tooling/docs/wp-primitives-contract.md @@ -1,10 +1,10 @@ -# wp-framework contract +# wp-primitives contract -The scaffolds under `scaffolds/wp/` and `scaffolds/block/` in `@rtcamp/wp-tooling` generate PHP code that extends abstract base classes shipped by `rtcamp/wp-framework`. This document specifies the class surface those scaffolds expect. +The scaffolds under `scaffolds/wp/` and `scaffolds/block/` in `@rtcamp/wp-tooling` generate PHP code that extends abstract base classes shipped by `rtcamp/wp-primitives`. This document specifies the class surface those scaffolds expect. -If you are working in `wp-framework`, treat this as the requirements list. If you are authoring a new `wp/...` or `block/...` scaffold here, treat it as the available API. +If you are working in `wp-primitives`, treat this as the requirements list. If you are authoring a new `wp/...` or `block/...` scaffold here, treat it as the available API. -The framework was previously published as `rtcamp/wp-php-toolkit` and renamed before its 1.0 release. References to the old name should be considered historical. +The package shipped as `rtcamp/wp-framework` for 1.0.x and was renamed to `rtcamp/wp-primitives` in 2.0.0. --- @@ -12,11 +12,11 @@ The framework was previously published as `rtcamp/wp-php-toolkit` and renamed be ``` "require": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } ``` -Namespace root: `rtCamp\WPFramework`. +Namespace root: `rtCamp\WPPrimitives`. --- @@ -34,7 +34,7 @@ The `Loader` also recognises `ConditionallyRegistrable` (skip when `can_register ## Abstract classes the scaffolds target -All paths below sit under `rtCamp\WPFramework\Contracts\Abstracts\`. +All paths below sit under `rtCamp\WPPrimitives\Contracts\Abstracts\`. ### `AbstractPostType` (extended by `scaffolds/wp/cpt`) @@ -156,7 +156,7 @@ Wiring: the framework's `Loader` instantiates each listed class and calls `regis ## Interfaces -All paths below sit under `rtCamp\WPFramework\Contracts\Interfaces\`. +All paths below sit under `rtCamp\WPPrimitives\Contracts\Interfaces\`. ### `Registrable` (implemented by `scaffolds/wp/cron`, `scaffolds/wp/registrable`) @@ -192,7 +192,7 @@ interface CLICommand { ## Utility classes (targeted by `scaffolds/utility/*`) -All under `rtCamp\WPFramework\Utils\`. These are **plain classes with public constructors** — not +All under `rtCamp\WPPrimitives\Utils\`. These are **plain classes with public constructors** — not `Registrable`, not `Shareable`, not singletons, and none needs the `Container`. That is why the `utility/*` scaffolds wire them through an accessor on the consumer's static helper (`/Helpers/Util.php`) rather than adding them to an `AbstractModule` or `Main::CLASSES`: diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md index cff2e98..a230cc0 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md @@ -133,7 +133,7 @@ Map each feature the developer mentioned to one or more scaffold IDs from the ca | CI pipeline | `ci/cd-wporg` (or other CI scaffold) | The `utility/*` scaffolds are `source: package`: they write no files and return the -`rtcamp/wp-framework` dependency plus one accessor snippet for `/Helpers/Util.php`. +`rtcamp/wp-primitives` dependency plus one accessor snippet for `/Helpers/Util.php`. Check whether the project already exposes that accessor before applying the wiring — pasting in a method name that already exists in `Helpers\Util` is a PHP fatal error (cannot redeclare method). diff --git a/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json index b035be8..356f814 100644 --- a/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json @@ -2,9 +2,9 @@ "slug": "cache", "category": "utility", "name": "Cache utility", - "description": "Wires up the framework Cache utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Cache wraps the WordPress object cache with a per-plugin group namespace, group flushing, and the remember() / remember_swr() compute-once helpers.", + "description": "Wires up the framework Cache utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Cache wraps the WordPress object cache with a per-plugin group namespace, group flushing, and the remember() / remember_swr() compute-once helpers.", "source": "package", - "module_class": "rtCamp\\WPFramework\\Utils\\Cache", + "module_class": "rtCamp\\WPPrimitives\\Utils\\Cache", "files": [], "inputs": [ { @@ -24,11 +24,11 @@ { "target_file": "{{base_path}}/Helpers/Util.php", "anchor": "// scaffold:utility/cache", - "snippet_template": "/**\n * The project's shared framework Cache, namespaced so its groups can never\n * collide with another consumer's.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Cache Shared cache.\n */\npublic static function cache(): \\rtCamp\\WPFramework\\Utils\\Cache {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Cache( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", - "description": "Add a shared accessor for the framework Cache to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::cache()->remember( ... ) works with nothing further to wire. Do not add the framework Cache to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context. If the project instead shares services through the framework Shareable contract (a Core\\Cache class extending the framework Cache, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern and add a one-line accessor there. If only one class needs the cache, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-framework to be installed first." + "snippet_template": "/**\n * The project's shared framework Cache, namespaced so its groups can never\n * collide with another consumer's.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Cache Shared cache.\n */\npublic static function cache(): \\rtCamp\\WPPrimitives\\Utils\\Cache {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Cache( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", + "description": "Add a shared accessor for the framework Cache to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::cache()->remember( ... ) works with nothing further to wire. Do not add the framework Cache to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context. If the project instead shares services through the framework Shareable contract (a Core\\Cache class extending the framework Cache, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern and add a one-line accessor there. If only one class needs the cache, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-primitives to be installed first." } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json index 8daf982..db5a914 100644 --- a/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json @@ -2,9 +2,9 @@ "slug": "feature-selector", "category": "utility", "name": "Feature selector utility", - "description": "Wires up the framework FeatureSelector utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. FeatureSelector registers named feature flags and resolves each one from a PHP constant or a shared option through register / is_enabled / enable / disable, failing closed for anything unregistered.", + "description": "Wires up the framework FeatureSelector utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. FeatureSelector registers named feature flags and resolves each one from a PHP constant or a shared option through register / is_enabled / enable / disable, failing closed for anything unregistered.", "source": "package", - "module_class": "rtCamp\\WPFramework\\Utils\\FeatureSelector", + "module_class": "rtCamp\\WPPrimitives\\Utils\\FeatureSelector", "files": [], "inputs": [ { @@ -24,11 +24,11 @@ { "target_file": "{{base_path}}/Helpers/Util.php", "anchor": "// scaffold:utility/feature-selector", - "snippet_template": "/**\n * The project's shared framework FeatureSelector. One instance holds the whole\n * flag registry, so every caller sees the same registered features.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\FeatureSelector Shared feature selector.\n */\npublic static function feature_selector(): \\rtCamp\\WPFramework\\Utils\\FeatureSelector {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\FeatureSelector( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", - "description": "Add a shared accessor for the framework FeatureSelector to the project's static helper class. The method is named feature_selector(); if that name is already taken in the helper class, rename it before pasting. The class is spelled FeatureSelector, not Feature_Selector. The snippet is self-contained: paste it inside the existing helper class body, register flags once during bootstrap with Util::feature_selector()->register( 'new-checkout' ), then read them with Util::feature_selector()->is_enabled( 'new-checkout' ). Sharing one instance is deliberate rather than incidental: the flag registry lives on the instance and unregistered flags fail closed, so a fresh instance per call would report every feature as disabled. Do not add the framework FeatureSelector to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context and derive the wrong option key. For an admin UI over these flags, subclass the framework FeatureSelectorSettingsPage and return this accessor from its get_selector(). Requires rtcamp/wp-framework to be installed first." + "snippet_template": "/**\n * The project's shared framework FeatureSelector. One instance holds the whole\n * flag registry, so every caller sees the same registered features.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\FeatureSelector Shared feature selector.\n */\npublic static function feature_selector(): \\rtCamp\\WPPrimitives\\Utils\\FeatureSelector {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\FeatureSelector( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", + "description": "Add a shared accessor for the framework FeatureSelector to the project's static helper class. The method is named feature_selector(); if that name is already taken in the helper class, rename it before pasting. The class is spelled FeatureSelector, not Feature_Selector. The snippet is self-contained: paste it inside the existing helper class body, register flags once during bootstrap with Util::feature_selector()->register( 'new-checkout' ), then read them with Util::feature_selector()->is_enabled( 'new-checkout' ). Sharing one instance is deliberate rather than incidental: the flag registry lives on the instance and unregistered flags fail closed, so a fresh instance per call would report every feature as disabled. Do not add the framework FeatureSelector to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context and derive the wrong option key. For an admin UI over these flags, subclass the framework FeatureSelectorSettingsPage and return this accessor from its get_selector(). Requires rtcamp/wp-primitives to be installed first." } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json index e3df50b..d6fc447 100644 --- a/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json @@ -2,9 +2,9 @@ "slug": "logger", "category": "utility", "name": "Logger utility", - "description": "Wires up the framework Logger utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Logger writes prefixed debug / info / warning / error lines and stays silent unless WP_DEBUG is on.", + "description": "Wires up the framework Logger utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Logger writes prefixed debug / info / warning / error lines and stays silent unless WP_DEBUG is on.", "source": "package", - "module_class": "rtCamp\\WPFramework\\Utils\\Logger", + "module_class": "rtCamp\\WPPrimitives\\Utils\\Logger", "files": [], "inputs": [ { @@ -24,11 +24,11 @@ { "target_file": "{{base_path}}/Helpers/Util.php", "anchor": "// scaffold:utility/logger", - "snippet_template": "/**\n * The project's shared framework Logger. Silent unless WP_DEBUG.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Logger Shared logger.\n */\npublic static function logger(): \\rtCamp\\WPFramework\\Utils\\Logger {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Logger( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", - "description": "Add a shared accessor for the framework Logger to the project's static helper class. Check for an existing logger accessor before applying, and skip this wiring if one is already there: the rtCamp skeleton ships a Core\\Logger class extending the framework Logger, registered in Main::CLASSES and reached through Util::logger(), so re-applying the snippet would redeclare the method and fatal. Otherwise the snippet is self-contained: paste it inside the existing helper class body and Util::logger()->info( 'Cache warmed', array( 'items' => 42 ) ) works with nothing further to wire. Do not add the framework Logger to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with the framework's own default prefix rather than this project's. Requires rtcamp/wp-framework to be installed first." + "snippet_template": "/**\n * The project's shared framework Logger. Silent unless WP_DEBUG.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Logger Shared logger.\n */\npublic static function logger(): \\rtCamp\\WPPrimitives\\Utils\\Logger {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Logger( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", + "description": "Add a shared accessor for the framework Logger to the project's static helper class. Check for an existing logger accessor before applying, and skip this wiring if one is already there: the rtCamp skeleton ships a Core\\Logger class extending the framework Logger, registered in Main::CLASSES and reached through Util::logger(), so re-applying the snippet would redeclare the method and fatal. Otherwise the snippet is self-contained: paste it inside the existing helper class body and Util::logger()->info( 'Cache warmed', array( 'items' => 42 ) ) works with nothing further to wire. Do not add the framework Logger to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with the framework's own default prefix rather than this project's. Requires rtcamp/wp-primitives to be installed first." } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json index 98a1c5a..d12e729 100644 --- a/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json @@ -2,9 +2,9 @@ "slug": "timer", "category": "utility", "name": "Timer utility", - "description": "Wires up the framework Timer utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Timer measures elapsed time with start / stop / lap and reports through get / get_all, warning via _doing_it_wrong() on misuse rather than throwing.", + "description": "Wires up the framework Timer utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Timer measures elapsed time with start / stop / lap and reports through get / get_all, warning via _doing_it_wrong() on misuse rather than throwing.", "source": "package", - "module_class": "rtCamp\\WPFramework\\Utils\\Timer", + "module_class": "rtCamp\\WPPrimitives\\Utils\\Timer", "files": [], "inputs": [ { @@ -24,11 +24,11 @@ { "target_file": "{{base_path}}/Helpers/Util.php", "anchor": "// scaffold:utility/timer", - "snippet_template": "/**\n * The project's shared framework Timer. One instance keeps every lap on a\n * single timeline, so callers in different classes can read each other's marks.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Timer Shared timer.\n */\npublic static function timer(): \\rtCamp\\WPFramework\\Utils\\Timer {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Timer();\n\t}\n\n\treturn $instance;\n}", - "description": "Add a shared accessor for the framework Timer to the project's static helper class. The framework Timer takes no constructor arguments, so there is nothing to configure and the context slug is not used here. Sharing one instance is deliberate rather than incidental: labels and laps accumulate on the instance, so a fresh instance per call would lose every mark taken elsewhere. The snippet is self-contained: paste it inside the existing helper class body and Util::timer()->start( 'import' ) works with nothing further to wire. Do not add the framework Timer to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct an instance nothing can reach. If the project instead shares services through the framework Shareable contract (a Core class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. Requires rtcamp/wp-framework to be installed first." + "snippet_template": "/**\n * The project's shared framework Timer. One instance keeps every lap on a\n * single timeline, so callers in different classes can read each other's marks.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Timer Shared timer.\n */\npublic static function timer(): \\rtCamp\\WPPrimitives\\Utils\\Timer {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Timer();\n\t}\n\n\treturn $instance;\n}", + "description": "Add a shared accessor for the framework Timer to the project's static helper class. The framework Timer takes no constructor arguments, so there is nothing to configure and the context slug is not used here. Sharing one instance is deliberate rather than incidental: labels and laps accumulate on the instance, so a fresh instance per call would lose every mark taken elsewhere. The snippet is self-contained: paste it inside the existing helper class body and Util::timer()->start( 'import' ) works with nothing further to wire. Do not add the framework Timer to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct an instance nothing can reach. If the project instead shares services through the framework Shareable contract (a Core class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. Requires rtcamp/wp-primitives to be installed first." } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json b/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json index 87c4eca..38a6560 100644 --- a/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json @@ -2,9 +2,9 @@ "slug": "transients", "category": "utility", "name": "Transients utility", - "description": "Wires up the framework Transients utility from rtcamp/wp-framework via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Transients wraps get_transient / set_transient / delete_transient behind a mandatory per-project key prefix.", + "description": "Wires up the framework Transients utility from rtcamp/wp-primitives via an accessor snippet. Writes no files: reports the Composer dependency and one accessor snippet for the developer or AI to paste in. Transients wraps get_transient / set_transient / delete_transient behind a mandatory per-project key prefix.", "source": "package", - "module_class": "rtCamp\\WPFramework\\Utils\\Transients", + "module_class": "rtCamp\\WPPrimitives\\Utils\\Transients", "files": [], "inputs": [ { @@ -24,11 +24,11 @@ { "target_file": "{{base_path}}/Helpers/Util.php", "anchor": "// scaffold:utility/transients", - "snippet_template": "/**\n * The project's shared framework Transients store, prefixed so its keys can\n * never collide with another consumer's.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Transients Shared transients store.\n */\npublic static function transients(): \\rtCamp\\WPFramework\\Utils\\Transients {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Transients( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", - "description": "Add a shared accessor for the framework Transients store to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::transients()->get( 'key' ) works with nothing further to wire. The constructor prefix is mandatory and readonly — never pass an empty string. Do not add the framework Transients to a Modules class or to Main::CLASSES: it implements neither Registrable nor Shareable, so the framework Loader would try to construct it with no argument and fatal on the missing prefix. If the project instead shares services through the framework Shareable contract (a Core\\Transients class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. If only one class needs transients, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-framework to be installed first." + "snippet_template": "/**\n * The project's shared framework Transients store, prefixed so its keys can\n * never collide with another consumer's.\n *\n * @return \\rtCamp\\WPPrimitives\\Utils\\Transients Shared transients store.\n */\npublic static function transients(): \\rtCamp\\WPPrimitives\\Utils\\Transients {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPPrimitives\\Utils\\Transients( '{{context_slug}}' );\n\t}\n\n\treturn $instance;\n}", + "description": "Add a shared accessor for the framework Transients store to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::transients()->get( 'key' ) works with nothing further to wire. The constructor prefix is mandatory and readonly — never pass an empty string. Do not add the framework Transients to a Modules class or to Main::CLASSES: it implements neither Registrable nor Shareable, so the framework Loader would try to construct it with no argument and fatal on the missing prefix. If the project instead shares services through the framework Shareable contract (a Core\\Transients class extending the framework class, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern. If only one class needs transients, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-primitives to be installed first." } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json index 4b7d35b..8ab3391 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/scaffold.json @@ -92,6 +92,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache index 374032a..d7621a6 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/block-bindings.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * @link https://developer.wordpress.org/reference/functions/register_block_bindings_source/ * * @package {{namespace}} @@ -15,7 +15,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; use WP_Block; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Registers a Block Bindings source (core since WP 6.5). diff --git a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache index 23ec797..82ce067 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/block-bindings/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ @@ -18,7 +18,7 @@ use WP_Block; use WP_Block_Bindings_Registry; use WP_Block_Type_Registry; use WP_UnitTestCase; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Class {{class}}Test diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json index 9b5bbfc..572237d 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/scaffold.json @@ -105,6 +105,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache index 341309d..239b22c 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/script-module.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * @link https://developer.wordpress.org/reference/functions/wp_register_script_module/ * * @package {{namespace}} @@ -14,7 +14,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Registers and enqueues a Script Module (core since WP 6.5). @@ -94,8 +94,8 @@ final class {{class}} implements Registrable { /** * Filesystem path of the built module. * - * The dirname() depth is derived from the scaffold's base_path, so a - * custom autoload directory still resolves to the project root. + * Resolved from this class's directory up to the project root, so update + * it here and in get_module_url() if the class moves. */ public function get_module_path(): string { return {{project_root}} . '/{{build_dir}}/{{module_slug}}.js'; @@ -104,9 +104,9 @@ final class {{class}} implements Registrable { /** * Public URL of the built module. * - * Plugin_dir_url() takes a file path and returns its directory's URL. - * A file name appended to the derived project root gives it that directory; - * no file is read, so the synthetic root-level path need not exist. + * The plugin_dir_url() helper returns the URL of a file's directory, so it + * gets a file name under the project root. The file is never read, so it + * need not exist. */ public function get_module_url(): string { return plugin_dir_url( {{project_root}} . '/{{class}}.php' ) . '{{build_dir}}/{{module_slug}}.js'; diff --git a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache index a317d9d..6de208b 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/script-module/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ @@ -15,7 +15,7 @@ namespace {{tests_namespace}}; use {{namespace}}\{{class}}; use WP_UnitTestCase; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Class {{class}}Test @@ -59,11 +59,10 @@ final class {{class}}Test extends WP_UnitTestCase { /** * The URL is the plugin root's, plus the build-relative path. * - * Path and URL are derived at different dirname() depths, because - * plugin_dir_url() takes a file and returns its directory's URL, so this - * asserts the two still land on the same directory. A depth that has - * drifted resolves above the plugin, to /{{build_dir}}/..., and - * 404s. + * The plugin_dir_url() helper takes a file and returns its directory's URL, + * so this asserts path and URL still land on the same directory. A root + * that has drifted resolves outside the plugin, to + * /{{build_dir}}/..., and 404s. */ public function test_module_url_resolves_inside_the_plugin_directory(): void { $instance = new {{class}}(); diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json index 06951f6..d6feaa1 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/scaffold.json @@ -85,6 +85,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/speculation.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/speculation.php.mustache index b9f4571..ed2ee2c 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/speculation.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/speculation.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * @link https://developer.wordpress.org/reference/functions/wp_get_speculation_rules/ * * @package {{namespace}} @@ -14,7 +14,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Customises WordPress core's Speculation Rules API (core since WP 6.8). diff --git a/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/test.php.mustache index 59685df..e685595 100644 --- a/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp-api/speculation/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ @@ -15,7 +15,7 @@ namespace {{tests_namespace}}; use {{namespace}}\{{class}}; use WP_UnitTestCase; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Class {{class}}Test diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json index 0a9ee5b..7ce0217 100644 --- a/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/scaffold.json @@ -2,7 +2,7 @@ "slug": "admin-page", "category": "wp", "name": "Admin Page", - "description": "Adds a custom admin menu page extending AbstractAdminPage from rtcamp/wp-framework. Top-level by default; pass --parent_slug for a submenu.", + "description": "Adds a custom admin menu page extending AbstractAdminPage from rtcamp/wp-primitives. Top-level by default; pass --parent_slug for a submenu.", "source": "template", "inputs": [ { @@ -96,6 +96,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache index 4a0b7fa..1cebbd3 100644 --- a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/admin-page.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractAdminPage; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractAdminPage; /** * Registers the {{page_title}} admin menu page. diff --git a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache index 12e0b35..2b94fc4 100644 --- a/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/admin-page/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json index 58725da..3140b2e 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/scaffold.json @@ -2,7 +2,7 @@ "slug": "block-dynamic", "category": "wp", "name": "Dynamic block", - "description": "Adds a server-rendered Gutenberg block: one PHP class extending AbstractBlock from rtcamp/wp-framework (handles registration and render), plus block.json + edit.js + index.js for the editor side.", + "description": "Adds a server-rendered Gutenberg block: one PHP class extending AbstractBlock from rtcamp/wp-primitives (handles registration and render), plus block.json + edit.js + index.js for the editor side.", "source": "template", "inputs": [ { @@ -119,6 +119,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache index ccc7518..a8bfcf4 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/block-class.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -14,7 +14,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; use WP_Block; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractBlock; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractBlock; /** * Registers the {{vendor}}/{{slug}} dynamic block. @@ -32,8 +32,7 @@ class {{class}} extends AbstractBlock { * Directory containing the compiled block.json + bundled index.js that * WordPress registers. Points at the build output (not the source tree), * so the editor receives the bundled assets and not raw ES modules. - * Run `npm run build` to populate this directory. The root depth follows - * base_path, including nested and root-level autoload directories. + * Run `npm run build` to populate this directory. * * Protected to match AbstractBlock, which declares it protected and calls it * internally. Narrowed to a non-null `string` (the parent declares diff --git a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache index 278e68d..2fce722 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-dynamic/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json index 413f1ae..aaf70b2 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/scaffold.json @@ -121,7 +121,7 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" }, "npm_dev_dependencies": { "@wordpress/block-editor": "^16.0.0", diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache index d26d8a5..7b47df8 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/block-registrar.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * @link https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/ * * @package {{namespace}} @@ -14,7 +14,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Registers the {{vendor}}/{{slug}} block from its build output. diff --git a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache index 3543f4c..027f302 100644 --- a/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/block-interactive/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ @@ -16,7 +16,7 @@ namespace {{tests_namespace}}; use {{namespace}}\{{class}}; use WP_Block_Type_Registry; use WP_UnitTestCase; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Class {{class}}Test diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json index 544a83b..d90c648 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/cli/scaffold.json @@ -2,7 +2,7 @@ "slug": "cli", "category": "wp", "name": "WP-CLI command", - "description": "Adds a WP-CLI command class implementing the CLICommand interface from rtcamp/wp-framework. Static get_name / get_description / run; the consumer's Cli module wires it through WP_CLI::add_command().", + "description": "Adds a WP-CLI command class implementing the CLICommand interface from rtcamp/wp-primitives. Static get_name / get_description / run; the consumer's Cli module wires it through WP_CLI::add_command().", "source": "template", "inputs": [ { @@ -68,6 +68,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache index a952418..891f170 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cli/templates/command.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -14,7 +14,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; use WP_CLI; -use rtCamp\WPFramework\Contracts\Interfaces\CLICommand; +use rtCamp\WPPrimitives\Contracts\Interfaces\CLICommand; /** * Registers the {{name}} WP-CLI command. diff --git a/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache index 2547fdc..739b3a7 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cli/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ @@ -15,7 +15,7 @@ namespace {{tests_namespace}}; use {{namespace}}\{{class}}; use PHPUnit\Framework\TestCase; -use rtCamp\WPFramework\Contracts\Interfaces\CLICommand; +use rtCamp\WPPrimitives\Contracts\Interfaces\CLICommand; /** * Class {{class}}Test diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json index feb100d..51cd4d2 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/cpt/scaffold.json @@ -2,7 +2,7 @@ "slug": "cpt", "category": "wp", "name": "Custom Post Type", - "description": "Adds a CPT class extending AbstractPostType from rtcamp/wp-framework. One class per post type; the consumer's PostTypes module instantiates it via the Loader.", + "description": "Adds a CPT class extending AbstractPostType from rtcamp/wp-primitives. One class per post type; the consumer's PostTypes module instantiates it via the Loader.", "source": "template", "inputs": [ { @@ -96,6 +96,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache index 6b9b0a3..a0fbbbe 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/post-type.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractPostType; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractPostType; /** * Registers the {{singular}} custom post type. diff --git a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache index 64ad4ae..cf24b18 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cpt/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json index 8676051..d941f0b 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/cron/scaffold.json @@ -2,7 +2,7 @@ "slug": "cron", "category": "wp", "name": "Cron job", - "description": "Adds a cron job class implementing the Registrable interface from rtcamp/wp-framework. The class schedules a wp-cron event in register_hooks() and binds its handler to the chosen action.", + "description": "Adds a cron job class implementing the Registrable interface from rtcamp/wp-primitives. The class schedules a wp-cron event in register_hooks() and binds its handler to the chosen action.", "source": "template", "inputs": [ { @@ -74,6 +74,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache index 2cc845b..ab0da98 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cron/templates/cron.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Schedules and handles the {{name}} cron job. diff --git a/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache index ae71bfd..0572c55 100644 --- a/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/cron/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json index 83437b7..c5f362c 100644 --- a/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/module/scaffold.json @@ -68,6 +68,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache b/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache index ce13a1c..a5fa963 100644 --- a/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/module/templates/module.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractModule; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractModule; /** * Groups related Registrable classes for the framework Loader to boot. diff --git a/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache index 30cc3c6..08e6ca5 100644 --- a/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/module/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json index 7ae37a8..301e7e5 100644 --- a/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/registrable/scaffold.json @@ -2,7 +2,7 @@ "slug": "registrable", "category": "wp", "name": "Registrable Service", - "description": "Escape hatch for hook-registering classes that don't fit a specific abstract. Generates a class implementing the Registrable interface from rtcamp/wp-framework with an empty register_hooks() body.", + "description": "Escape hatch for hook-registering classes that don't fit a specific abstract. Generates a class implementing the Registrable interface from rtcamp/wp-primitives with an empty register_hooks() body.", "source": "template", "inputs": [ { @@ -63,6 +63,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache index 1afd97e..2dba31e 100644 --- a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/registrable.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Registers this service's WordPress hooks through the framework Loader. diff --git a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache index bf455c4..bba6620 100644 --- a/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/registrable/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ @@ -15,7 +15,7 @@ namespace {{tests_namespace}}; use {{namespace}}\{{class}}; use WP_UnitTestCase; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\WPPrimitives\Contracts\Interfaces\Registrable; /** * Class {{class}}Test diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json index 2f671a9..c94d1bf 100644 --- a/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/rest/scaffold.json @@ -2,7 +2,7 @@ "slug": "rest", "category": "wp", "name": "REST controller", - "description": "Adds a REST API controller extending AbstractRESTController from rtcamp/wp-framework. The base extends WP_REST_Controller; the consumer's Rest module instantiates each controller via the Loader.", + "description": "Adds a REST API controller extending AbstractRESTController from rtcamp/wp-primitives. The base extends WP_REST_Controller; the consumer's Rest module instantiates each controller via the Loader.", "source": "template", "inputs": [ { @@ -73,6 +73,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache b/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache index dd1ffa4..529490e 100644 --- a/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/rest/templates/controller.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractRESTController; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractRESTController; /** * Registers REST routes for the {{name}} resource. diff --git a/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache index d046286..01ceae5 100644 --- a/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/rest/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json index a88378f..7d55b15 100644 --- a/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/scaffold.json @@ -2,7 +2,7 @@ "slug": "settings-page", "category": "wp", "name": "Settings Page", - "description": "Adds a Settings API page extending AbstractSettingsPage from rtcamp/wp-framework. Generates a page plus a worked example of register_setting() declarations.", + "description": "Adds a Settings API page extending AbstractSettingsPage from rtcamp/wp-primitives. Generates a page plus a worked example of register_setting() declarations.", "source": "template", "inputs": [ { @@ -96,6 +96,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache index 526513f..9002e06 100644 --- a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/settings-page.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractSettingsPage; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractSettingsPage; /** * Registers the {{page_title}} settings page via the Settings API. diff --git a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache index ce31cd4..d8baf31 100644 --- a/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/settings-page/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json index dd8e074..87383e4 100644 --- a/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/scaffold.json @@ -2,7 +2,7 @@ "slug": "shortcode", "category": "wp", "name": "Shortcode", - "description": "Adds a shortcode class extending AbstractShortcode from rtcamp/wp-framework. One class per shortcode tag; the consumer's Shortcodes module instantiates it via the Loader.", + "description": "Adds a shortcode class extending AbstractShortcode from rtcamp/wp-primitives. One class per shortcode tag; the consumer's Shortcodes module instantiates it via the Loader.", "source": "template", "inputs": [ { @@ -63,6 +63,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache index 16fd1e6..7058df3 100644 --- a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/shortcode.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractShortcode; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractShortcode; /** * Registers the [{{tag}}] shortcode. diff --git a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache index b3400e3..ec39166 100644 --- a/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/shortcode/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json index a3bdc5a..c9210c0 100644 --- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/scaffold.json @@ -2,7 +2,7 @@ "slug": "taxonomy", "category": "wp", "name": "Taxonomy", - "description": "Adds a taxonomy class extending AbstractTaxonomy from rtcamp/wp-framework. One class per taxonomy; the consumer's Taxonomies module instantiates it via the Loader.", + "description": "Adds a taxonomy class extending AbstractTaxonomy from rtcamp/wp-primitives. One class per taxonomy; the consumer's Taxonomies module instantiates it via the Loader.", "source": "template", "inputs": [ { @@ -96,6 +96,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache index 519d1b9..71aa9c8 100644 --- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/taxonomy.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -13,7 +13,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractTaxonomy; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractTaxonomy; /** * Registers the {{singular}} taxonomy. diff --git a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache index e93188c..0a7dee2 100644 --- a/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/taxonomy/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json b/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json index 319fb1a..34ed22d 100644 --- a/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/wp/user-role/scaffold.json @@ -2,7 +2,7 @@ "slug": "user-role", "category": "wp", "name": "User Role", - "description": "Adds a versioned custom user role class extending AbstractUserRole from rtcamp/wp-framework. The framework re-registers the role only when get_version() increases.", + "description": "Adds a versioned custom user role class extending AbstractUserRole from rtcamp/wp-primitives. The framework re-registers the role only when get_version() increases.", "source": "template", "inputs": [ { @@ -80,6 +80,6 @@ } ], "composer_dependencies": { - "rtcamp/wp-framework": "^1.0" + "rtcamp/wp-primitives": "^2.0" } } diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache index a478d9b..9d69537 100644 --- a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/test.php.mustache @@ -4,7 +4,7 @@ * * Scaffolded by @rtcamp/wp-tooling. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{tests_namespace}} */ diff --git a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache index 7d4449d..f393620 100644 --- a/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache +++ b/node-packages/wp-tooling/scaffolds/wp/user-role/templates/user-role.php.mustache @@ -7,7 +7,7 @@ * Bump get_version() whenever get_capabilities() changes so the framework * re-applies the role on the next admin_init. * - * @link https://github.com/rtCamp/wp-framework + * @link https://github.com/rtCamp/wp-primitives * * @package {{namespace}} */ @@ -16,7 +16,7 @@ declare( strict_types = 1 ); namespace {{namespace}}; -use rtCamp\WPFramework\Contracts\Abstracts\AbstractUserRole; +use rtCamp\WPPrimitives\Contracts\Abstracts\AbstractUserRole; /** * Registers the {{display_name}} user role. diff --git a/node-packages/wp-tooling/skills/setup/SKILL.md b/node-packages/wp-tooling/skills/setup/SKILL.md index cff2e98..a230cc0 100644 --- a/node-packages/wp-tooling/skills/setup/SKILL.md +++ b/node-packages/wp-tooling/skills/setup/SKILL.md @@ -133,7 +133,7 @@ Map each feature the developer mentioned to one or more scaffold IDs from the ca | CI pipeline | `ci/cd-wporg` (or other CI scaffold) | The `utility/*` scaffolds are `source: package`: they write no files and return the -`rtcamp/wp-framework` dependency plus one accessor snippet for `/Helpers/Util.php`. +`rtcamp/wp-primitives` dependency plus one accessor snippet for `/Helpers/Util.php`. Check whether the project already exposes that accessor before applying the wiring — pasting in a method name that already exists in `Helpers\Util` is a PHP fatal error (cannot redeclare method). 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 6ea132d..c284ef9 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -783,7 +783,7 @@ describe('utility/* package scaffolds', () => { expect(result.ai.tests).toEqual([]); expect(result.developer.secrets).toEqual([]); expect(result.developer.install.composer).toEqual({ - 'rtcamp/wp-framework': '^1.0', + 'rtcamp/wp-primitives': '^2.0', }); expect(result.ai.wiring).toHaveLength(1); @@ -791,7 +791,7 @@ describe('utility/* package scaffolds', () => { expect(w.anchor).toBe(`// scaffold:${id}`); expect(w.targetFile).toBe('includes/Helpers/Util.php'); expect(w.snippet).toContain( - `\\rtCamp\\WPFramework\\Utils\\${className}` + `\\rtCamp\\WPPrimitives\\Utils\\${className}` ); // The engine passes `description` through verbatim, so it must not // carry a placeholder that would reach the caller unresolved. @@ -809,7 +809,7 @@ describe('utility/* package scaffolds', () => { 'rtcamp_project_name_features' ); expect(result.ai.wiring[0].snippet).toContain( - "new \\rtCamp\\WPFramework\\Utils\\Cache( 'rtcamp_project_name_features' )" + "new \\rtCamp\\WPPrimitives\\Utils\\Cache( 'rtcamp_project_name_features' )" ); }); @@ -848,7 +848,7 @@ describe('utility/* package scaffolds', () => { { dryRun: true, cwd: targetWithComposerName() } ); const snippet = result.ai.wiring[0].snippet; - expect(snippet).toContain('new \\rtCamp\\WPFramework\\Utils\\Timer()'); + expect(snippet).toContain('new \\rtCamp\\WPPrimitives\\Utils\\Timer()'); expect(snippet).not.toContain('rtcamp_project_name_features'); }); }); From e1e733142548278622a20e1f2a8200c18833512c Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:31:20 +0530 Subject: [PATCH 03/15] ci: lint and test every pull request and push to main Nothing ran the suites on a pull request, while every merge to main is split straight onto the npm/* branches that the skeletons and the shared workflows install from. A broken merge therefore reached new installs with no gate in between. The new workflow runs every workspace lint and Jest suite with coverage thresholds on Node 22.19 (the engines floor) and 24, and installs and tests each composer package standalone, the way it ships after the subtree split, on PHP 8.2, 8.3 and 8.4. --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 4 +- 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a642d17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +# Lints and tests every pull request and every push to main. +# +# Until this workflow existed nothing ran the suites on a PR, while every merge to +# main is split straight onto the npm/* branches that consumers install from, so a +# broken merge reached new installs with no gate in between. +# +# Node: every npm workspace's lint + Jest suite with coverage (each workspace's +# jest.config.js holds its own coverageThreshold), on the engines floor and the +# current LTS. +# +# PHP: each composer-packages/* package is installed and tested standalone, the +# way it ships after the subtree split, on the consumer floor (8.2) and newer. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + node: + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 22.19 is the engines floor of every package; 24 is the current LTS. + node: ["22.19", "24"] + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install workspaces + run: npm ci --no-audit --no-fund + + - name: Lint + run: npm run lint + + - name: Test with coverage + run: npm run test:coverage + + php: + name: PHP ${{ matrix.php }} / wp-${{ matrix.package }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.2", "8.3", "8.4"] + package: [phpcs, phpstan] + defaults: + run: + working-directory: composer-packages/${{ matrix.package }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict --no-check-lock + + - name: Install the package standalone + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Test + run: composer test diff --git a/AGENTS.md b/AGENTS.md index b55231d..f5d3500 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ composer install # install s - `node-packages/tailwind-config/` — `@rtcamp/tailwind-config`, Tailwind v4 PostCSS config + `theme.json` webpack plugin - `composer-packages/phpcs/` — `rtcamp/wp-phpcs`, PHP_CodeSniffer standards (`rtCampWP`, `rtCampWP-Basic`) - `composer-packages/phpstan/` — `rtcamp/wp-phpstan`, shared PHPStan baseline for WordPress projects -- `.github/workflows/` — the two subtree-split release workflows; no lint/test CI runs here +- `.github/workflows/` holds `ci.yml` (lint + tests on every PR and push to main) and the two subtree-split release workflows ## Progressive discovery @@ -53,7 +53,7 @@ For full release-mechanism details, see `.github/workflows/release-php.yml` and - Subtree-split artifacts — the `npm/` branches and the `wp-phpcs`/`wp-phpstan` mirror repos — are generated. Never hand-edit them; a diverged target fails the next split run instead of being silently rewritten. - `npm install @rtcamp/wp-tooling` does not resolve from the public npm registry today; the current install path is a git URL (`#npm/wp-tooling`). Don't assume a registry publish exists yet — see Architectural decisions above. - Every one of the six packages carries its own `LICENSE` file — `git subtree split` only carries history of files *inside* the split directory, so the root `LICENSE` never reaches a mirror repo or split branch. -- No CI workflow lints or tests on every push — `.github/workflows/` only holds the two release/split workflows above. Run the Code quality commands locally before opening a PR. +- `ci.yml` runs every workspace lint, every Jest suite with its coverage threshold (Node 22.19 and 24), and each `composer-packages/*` suite installed standalone (PHP 8.2, 8.3 and 8.4) on every PR and push to main. A red CI blocks the merge that would otherwise ship straight onto the `npm/*` branches. - `.vscode/extensions.json` only recommends extensions from verified publishers (Microsoft, GitHub, Red Hat, EditorConfig Foundation) — don't add others, regardless of popularity. ## PR instructions From 182a9f159839c42c8e8a2b1e6e9a114f718fd89f Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:33:04 +0530 Subject: [PATCH 04/15] ci(release): repair diverged mirrors and narrow secret and token scope Both mirrors already carry a commit the split did not make (an org bot merged a Copilot review workflow into rtCamp/wp-phpcs and rtCamp/wp-phpstan on 2026-08-13). The release refuses to fast-forward over that, so the next tag would fail before reaching Packagist, and the only way out was a force push that also mints a throwaway tag. - release-php: add a resync_branch dispatch input that forces just the mirror branch back onto the split and pushes no tag, and say exactly that in the error when a release does not fast-forward. - release-php: reference the deploy keys by name instead of secrets[matrix.secret_name], which hands the job the whole secrets context, and check the secret name before the indirect lookup. - split-npm-packages: grant contents: write to the split job only. --- .github/workflows/release-php.yml | 42 +++++++++++++++++++++--- .github/workflows/split-npm-packages.yml | 11 +++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-php.yml b/.github/workflows/release-php.yml index 967136a..7cd68d9 100644 --- a/.github/workflows/release-php.yml +++ b/.github/workflows/release-php.yml @@ -4,8 +4,10 @@ name: Release Composer Packages # can serve rtcamp/wp-phpcs and rtcamp/wp-phpstan from their own repositories. # Fired by the tag that `vendor/bin/monorepo-builder release` pushes. # -# splitsh.json is the single source of truth for mirror repo -> subdirectory; -# to add or remove a package, edit that file, never this workflow. +# splitsh.json is the single source of truth for mirror repo -> subdirectory. +# To add a package, add it there and list its deploy-key secret in the split +# step's env below (secrets are referenced by name so the job only receives +# the deploy keys, never the whole secrets context). # # Each mirror is pushed with its own SSH deploy key (repo secret # SPLIT_KEY_, "Allow write access" required) rather than a PAT bound to @@ -29,6 +31,11 @@ on: type: boolean required: false default: false + resync_branch: + description: "Only force the mirror branch back onto the split (repairs a diverged mirror); pushes no tag" + type: boolean + required: false + default: false # Pushes to the mirrors must not race each other. concurrency: @@ -159,7 +166,10 @@ jobs: - name: Split ${{ matrix.package_directory }} → ${{ matrix.mirror_repo }} shell: bash env: - SSH_KEY: ${{ secrets[matrix.secret_name] }} + # Named explicitly: a dynamic secrets[...] lookup would hand the job the + # whole secrets context. A new mirror's key has to be listed here. + SPLIT_KEY_PHPCS: ${{ secrets.SPLIT_KEY_PHPCS }} + SPLIT_KEY_PHPSTAN: ${{ secrets.SPLIT_KEY_PHPSTAN }} SECRET_NAME: ${{ matrix.secret_name }} PKG_PATH: ${{ matrix.package_directory }} ORG: ${{ needs.build-matrix.outputs.organization }} @@ -167,11 +177,19 @@ jobs: BRANCH: ${{ needs.build-matrix.outputs.branch }} TAG: ${{ needs.build-matrix.outputs.tag }} FORCE_PUSH: ${{ github.event_name == 'workflow_dispatch' && inputs.force_push }} + RESYNC_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.resync_branch }} run: | set -euo pipefail + # The name comes from splitsh.json, so check its shape before the + # indirect expansion reads a variable by that name. + if [[ ! "$SECRET_NAME" =~ ^SPLIT_KEY_[A-Z0-9_]+$ ]]; then + echo "::error::Refusing deploy-key secret name '$SECRET_NAME': it must look like SPLIT_KEY_." + exit 1 + fi + SSH_KEY="${!SECRET_NAME:-}" if [[ -z "$SSH_KEY" ]]; then - echo "::error::No deploy key in $SECRET_NAME. Add the private half of ${ORG}/${REPO}'s write-enabled deploy key as that repository secret." + echo "::error::No deploy key in $SECRET_NAME. Add the private half of ${ORG}/${REPO}'s write-enabled deploy key as that repository secret, and list it in this step's env." exit 1 fi @@ -215,7 +233,21 @@ jobs: fi remote="git@github.com:$ORG/$REPO.git" - "${push[@]}" "$remote" "$split_sha:refs/heads/$BRANCH" + + # Repair mode: put the mirror branch back onto the split and stop, so a + # mirror that something else committed to (an org bot PR, a hand edit) + # can be fixed without minting a tag that Packagist would publish. + if [[ "$RESYNC_BRANCH" == "true" ]]; then + echo "::warning::resync_branch is on: $ORG/$REPO $BRANCH is being forced onto the split; no tag is pushed." + git push --force "$remote" "$split_sha:refs/heads/$BRANCH" + echo "- **$ORG/$REPO**: \`$BRANCH\` resynced to \`$split_sha\` (no tag)" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if ! "${push[@]}" "$remote" "$split_sha:refs/heads/$BRANCH"; then + echo "::error::$ORG/$REPO $BRANCH did not fast-forward: something other than this workflow committed to the mirror. Inspect what it holds, then run this workflow by dispatch on the $TAG tag with resync_branch enabled, and re-run this release run." + exit 1 + fi # Packagist versions a package from its tags, so this push is not optional. "${push[@]}" "$remote" "$split_sha:refs/tags/$TAG" diff --git a/.github/workflows/split-npm-packages.yml b/.github/workflows/split-npm-packages.yml index f4d7c85..088815e 100644 --- a/.github/workflows/split-npm-packages.yml +++ b/.github/workflows/split-npm-packages.yml @@ -31,9 +31,9 @@ on: - '.github/workflows/split-npm-packages.yml' workflow_dispatch: -# Pushes branches to this repository; nothing else is needed. +# Read-only by default; only the split job, which pushes the branches, gets write. permissions: - contents: write + contents: read # Branch pushes must not race each other. concurrency: @@ -113,11 +113,16 @@ jobs: name: Split ${{ matrix.package_name }} needs: build-matrix runs-on: ubuntu-latest + # Pushes the npm/* branches of this repository; nothing else is needed. + permissions: + contents: write strategy: fail-fast: false # one bad package must not hide the others matrix: ${{ fromJson(needs.build-matrix.outputs.matrix) }} steps: - - name: Checkout monorepo (full history) + # The persisted GITHUB_TOKEN is what the push below authenticates with, and + # this job uploads no artifacts, so there is nothing for it to leak into. + - name: Checkout monorepo (full history) # zizmor: ignore[artipacked] uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # the split walks every commit that touched the subtree From dd2d79fae0a25555a11b5ffe2711597784f56db2 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:33:28 +0530 Subject: [PATCH 05/15] chore(composer): add Packagist metadata to wp-phpcs and wp-phpstan Both packages are about to be submitted to Packagist from their split mirrors, which are read-only. Point homepage, support.issues and support.source at the monorepo so the Packagist page sends issues and contributions to the right place, and name rtCamp as the author. --- composer-packages/phpcs/CHANGELOG.md | 8 ++++++++ composer-packages/phpcs/composer.json | 11 +++++++++++ composer-packages/phpstan/CHANGELOG.md | 8 ++++++++ composer-packages/phpstan/composer.json | 11 +++++++++++ 4 files changed, 38 insertions(+) diff --git a/composer-packages/phpcs/CHANGELOG.md b/composer-packages/phpcs/CHANGELOG.md index fdba6c0..544bf85 100644 --- a/composer-packages/phpcs/CHANGELOG.md +++ b/composer-packages/phpcs/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to `rtcamp/wp-phpcs` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist + page links to the monorepo for issues and source (the mirror repository is a + read-only split). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/composer-packages/phpcs/composer.json b/composer-packages/phpcs/composer.json index 9be746f..5e6c566 100644 --- a/composer-packages/phpcs/composer.json +++ b/composer-packages/phpcs/composer.json @@ -11,6 +11,17 @@ "WordPress", "static analysis" ], + "homepage": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpcs", + "authors": [ + { + "name": "rtCamp", + "homepage": "https://rtcamp.com" + } + ], + "support": { + "issues": "https://github.com/rtCamp/wp-tooling/issues", + "source": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpcs" + }, "require": { "php": ">=8.2", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", diff --git a/composer-packages/phpstan/CHANGELOG.md b/composer-packages/phpstan/CHANGELOG.md index fe02320..9819406 100644 --- a/composer-packages/phpstan/CHANGELOG.md +++ b/composer-packages/phpstan/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to `rtcamp/wp-phpstan` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist + page links to the monorepo for issues and source (the mirror repository is a + read-only split). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/composer-packages/phpstan/composer.json b/composer-packages/phpstan/composer.json index 33ae839..b6851ca 100644 --- a/composer-packages/phpstan/composer.json +++ b/composer-packages/phpstan/composer.json @@ -9,6 +9,17 @@ "wordpress", "rtcamp" ], + "homepage": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpstan", + "authors": [ + { + "name": "rtCamp", + "homepage": "https://rtcamp.com" + } + ], + "support": { + "issues": "https://github.com/rtCamp/wp-tooling/issues", + "source": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpstan" + }, "require": { "php": ">=8.2", "phpstan/phpstan": "^2.0", From 31323d920ef01447d34bc64b25d295e9aedf06fc Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:37:02 +0530 Subject: [PATCH 06/15] fix(detect-changes): add --strict and randomise the GitHub output delimiter A failed git diff counted as zero changes, which skipped every job gated on the counts and left the run green. rtCamp/wp-shared-workflows works around it by grepping stderr. --strict (detectChanges({ strict: true })) now exits 1 with a DetectChangesError instead; it is off by default so existing callers see no change, and the stderr keeps the 'git diff failed' phrase the shared workflow matches. Multi-line outputs used the fixed heredoc delimiter EOF_WP_TOOLING, but the file list comes from the pull request: a file named EOF_WP_TOOLING followed by one named php-count=0 closed the heredoc early and overrode the real count. Each output now gets a random delimiter that no value line may equal. coverage-gate shares the formatter and gets the same fix. New tests run detect-changes against real git repositories (pull request base, push before-SHA, new-branch fallback, explicit base, soft and strict failure) and pin the forged-output case. --- node-packages/wp-tooling/CHANGELOG.md | 2 + .../wp-tooling/src/ci/detect-changes.js | 95 ++++- .../tests/ci/detect-changes-git.test.js | 338 ++++++++++++++++++ .../tests/ci/detect-changes.test.js | 6 +- 4 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 node-packages/wp-tooling/tests/ci/detect-changes-git.test.js diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index ae5b996..e21b437 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `detect-changes --strict` (library: `detectChanges({ strict: true })`): a failed `git diff` exits 1 (throws the new `DetectChangesError`, code `EDIFFFAIL`, exported from `@rtcamp/wp-tooling/ci`) instead of reporting zero changes, which skipped every job gated on the counts and left the run green. Off by default, so existing callers see no change; CI callers should turn it on. The stderr wording keeps the "git diff failed" phrase that rtCamp/wp-shared-workflows matches today. - `wp-tooling coverage-gate` subcommand (`src/ci/coverage-gate/` — `diff`, `reports`, `gate`, `render`, `cli`; `computeGate`, `parseClover`, `parseLcov`, `parseDiffHunks`, `GateError`, `UNMEASURED_POLICIES`, `DEFAULT_UNMEASURED_POLICY` exported from `@rtcamp/wp-tooling/ci`). Diffs committed changes only (`...HEAD`, `--unified=0 --find-renames --diff-filter=AMR --relative`) from `--working-dir`, intersects the added/modified lines with a Clover (`--coverage-clover`) or LCOV report, and exits 1 when the covered share of changed executable lines is below `--threshold` (default 80; exactly at the threshold passes — compared in integer hundredths of a percent, so 8 of 10 at 80 and 147 of 1500 at 9.8 pass). Only Clover `stmt`/`cond` lines count — `type="method"` signature lines don't. Report paths are matched to repo paths by path-segment suffix, so wp-env container paths and absolute LCOV paths resolve. Changed source files missing from the report are listed as "not measured" and never counted as covered; `--unmeasured warn|fail|ignore` (default `warn`) decides whether they annotate a warning, fail the gate, or are only listed. Test and dependency paths are skipped (`--exclude ` replaces the pattern, `--no-exclude` disables it); other out-of-scope files are counted in the summary. Base resolves from `--base`, then the `pull_request`/`merge_group` base SHA in `$GITHUB_EVENT_PATH`, then `origin/$GITHUB_BASE_REF`; with none (push events) the gate is skipped with a notice. A missing or empty report, or a missing base commit, exits 2 rather than passing. `--output github` writes `changed-lines`, `covered-lines`, `percent`, `unmeasured-files`, `passed` step outputs, a `$GITHUB_STEP_SUMMARY` table with per-file missed line ranges, and per-file annotations. An empty flag value counts as not given, so workflows can pass unset inputs through. `--soft-fail` exits 0 and downgrades failure annotations to warnings; `--dry-run` previews the files and annotations without emitting workflow commands. `--threshold` accepts a plain decimal from 0 to 100 with at most two decimals. For rtCamp/wp-shared-workflows#36. - `lint/i18n` scaffold — a standalone `phpcs.i18n.xml.dist` running only `WordPress.WP.I18n` against the project's text domain, plus a `lint:i18n` composer script. Standalone because the `text_domain` property is project-specific and the engine never edits an existing `phpcs.xml.dist`. The `text_domain` input is `required` (discovered from `.wp-tooling.json` `textDomain` when present) rather than defaulted, because the sniff silently loses its `MissingArgDomain` / `TextDomainMismatch` checks when the property is wrong or unset. The rendered ruleset deliberately omits `default` from the allowed domains — listing it downgrades a missing domain argument from the `MissingArgDomain` error to the `MissingArgDomainDefault` warning — with a commented opt-in for projects that intentionally reuse core strings. Companion to the i18n lens skill in `rtcamp/wp-devtools`. - `utility/*` package scaffolds — `utility/cache`, `utility/transients`, `utility/logger`, `utility/timer` and `utility/feature-selector` wire the `rtcamp/wp-primitives` `Utils\*` classes with no template files. Each is `source: "package"`: zero files written, `rtcamp/wp-primitives: ^2.0` reported under `developer.install.composer`, and one `ai.wiring` accessor snippet for `/Helpers/Util.php` under anchor `// scaffold:utility/`. `context_slug` is discovered from `composer.json:name` and normalised to snake_case (`rtcamp/acme-blog` → `rtcamp_acme_blog`); pass `--context_slug` to override. Nothing is installed and nothing is wired automatically. These utilities are neither `Registrable` nor `Shareable`, so the wiring targets the consumer's static helper rather than a module or `Main::CLASSES` — the `Loader` would otherwise construct them with a missing or empty constructor argument. @@ -43,6 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly. - Generated block JavaScript follows the current shared ESLint formatting rules. - PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics. +- `detect-changes --output github` and `coverage-gate --output github` write multi-line outputs under a random per-output heredoc delimiter instead of the fixed `EOF_WP_TOOLING`. The file lists come from the pull request, so a PR adding a file named `EOF_WP_TOOLING` followed by one named `php-count=0` could close the heredoc early and override the real count, skipping the PHP jobs. - `discover_from: composer.json:autoload.psr-4` now resolves **path** inputs (`base_path`, `*_path`, `*_dir`) from the map entry's directory instead of skipping them, grafted the same way namespaces already were — with a root of `Acme\Blog\` → `inc/`, a `base_path` default of `includes/Services` resolves to `inc/Services`. Previously the namespace was grafted but the directory kept the manifest default, so any project not laid out under `includes/` got a correctly-namespaced class written outside its autoload root, where it never loaded. Affects the 12 bundled scaffolds that declare PSR-4 discovery on `base_path`. Projects already using `includes/`, projects without a `composer.json`, and callers that pass `--base_path` explicitly are unaffected. - Feature finalization rolls back successful hooks when detection or state persistence fails, and defers next-step messages until persistence succeeds. Setup/reinit identity and version writes share rollback protection. Rename batches reuse one plan, support chains and swaps, and detect case-insensitive destination collisions. Cleanup targets and explicit example markers reject malformed values before mutation. - Init validates shared setup/manage selections before mutation, confirms setup choices before applying identity, and uses current persisted tokens for reinitialization. Version-only edits now update files and identity; reinit preserves one-shot example choices and refuses corrupt identity. diff --git a/node-packages/wp-tooling/src/ci/detect-changes.js b/node-packages/wp-tooling/src/ci/detect-changes.js index 08a1cea..06a8627 100644 --- a/node-packages/wp-tooling/src/ci/detect-changes.js +++ b/node-packages/wp-tooling/src/ci/detect-changes.js @@ -13,6 +13,7 @@ 'use strict'; const { execFileSync } = require('child_process'); +const crypto = require('crypto'); const fs = require('fs'); /** @@ -37,6 +38,19 @@ const DEFAULT_PATTERNS = { const DEFAULT_IGNORE = /\.github\/(?!workflows)(?!actions)|\.wordpress-org\/|docs\//; +/** + * Thrown in strict mode when the changed files cannot be computed, so a caller + * that gates jobs on the counts fails instead of reading "nothing changed". + */ +class DetectChangesError extends Error { + constructor(message, details = {}) { + super(message); + this.name = 'DetectChangesError'; + this.code = 'EDIFFFAIL'; + Object.assign(this, details); + } +} + /** * Compute per-bucket change counts (and optionally file lists) for a set of files. * @@ -45,14 +59,16 @@ const DEFAULT_IGNORE = * @param {RegExp|string|null} [options.ignore] Override default ignore regex. `null` disables ignoring. * @param {string} [options.base] Override the diff base ref. * @param {boolean} [options.includeFiles] When true, include `-files` arrays alongside counts. + * @param {boolean} [options.strict] When true, a failed `git diff` throws DetectChangesError instead of counting as no changes. * @return {Object} Counts keyed `-count`. When `includeFiles` is true, matching `-files` arrays are included. + * @throws {DetectChangesError} In strict mode, when `git diff` fails. */ function detectChanges(options = {}) { const ignore = resolveIgnore(options.ignore); const files = options.files !== undefined ? normaliseFiles(options.files) - : gitDiffFiles(options.base); + : gitDiffFiles(options.base, options.strict === true); const ignored = []; const relevant = []; @@ -143,13 +159,17 @@ function normaliseFiles(input) { /** * Run `git diff --name-only` against the resolved base ref. * - * Fails soft -- on any git error (shallow clone, missing ref) writes a clear - * message to stderr and returns an empty list rather than throwing. + * By default it fails soft: on any git error (shallow clone, missing ref) it + * writes a clear message to stderr and returns an empty list. That reads as + * "nothing changed", which skips every gated job green, so CI callers should + * pass `strict` and get a DetectChangesError instead. * - * @param {string} [explicitBase] - * @return {string[]} Changed file paths, or `[]` on failure. + * @param {string} [explicitBase] + * @param {boolean} [strict=false] Throw instead of returning `[]` on failure. + * @return {string[]} Changed file paths, or `[]` on a soft failure. + * @throws {DetectChangesError} When `strict` and `git diff` fails. */ -function gitDiffFiles(explicitBase) { +function gitDiffFiles(explicitBase, strict = false) { const base = explicitBase || resolveBaseRef(); try { const out = execFileSync('git', ['diff', '--name-only', base, 'HEAD'], { @@ -159,6 +179,12 @@ function gitDiffFiles(explicitBase) { return out.split(/\r?\n/).filter(Boolean); } catch (err) { const detail = (err.stderr || err.message || '').toString().trim(); + if (strict) { + throw new DetectChangesError( + `git diff failed against "${base}" (${detail})`, + { base, detail } + ); + } process.stderr.write( `detect-changes: git diff failed against "${base}" (${detail}). Treating as no changes.\n` ); @@ -304,6 +330,9 @@ function parseArgs(argv) { case '--include-files': opts.includeFiles = true; break; + case '--strict': + opts.strict = true; + break; case '--help': case '-h': opts.help = true; @@ -329,14 +358,26 @@ function readFilesArg(filesArg) { return fs.readFileSync(filesArg, 'utf8'); } -/** GHA heredoc delimiter. File paths cannot contain newlines, so a fixed sentinel is safe. */ -const GHA_HEREDOC = 'EOF_WP_TOOLING'; +/** + * A fresh, unguessable heredoc delimiter for one multi-line output. + * + * The file list comes from the pull request, so a fixed sentinel is forgeable: + * a PR adding a file named after it closes the heredoc early, and a following + * file named `php-count=0` then overrides the real count, skipping the jobs + * gated on it. + * + * @return {string} Delimiter of the form `ghadelim_<32 hex chars>`. + */ +function makeHeredocDelimiter() { + return `ghadelim_${crypto.randomBytes(16).toString('hex')}`; +} /** * Render one `$GITHUB_OUTPUT` line for a scalar or array value. * - * Arrays use the GitHub Actions heredoc syntax for multi-line outputs. - * Empty arrays render as `key=` (no heredoc) for compactness. + * Arrays use the GitHub Actions heredoc syntax for multi-line outputs, under a + * random delimiter that no value line may equal. Empty arrays render as `key=` + * (no heredoc) for compactness. * * @param {string} key * @param {number|string|string[]} value @@ -347,7 +388,11 @@ function formatGithubLine(key, value) { if (value.length === 0) { return `${key}=`; } - return `${key}<<${GHA_HEREDOC}\n${value.join('\n')}\n${GHA_HEREDOC}`; + let delimiter = makeHeredocDelimiter(); + while (value.includes(delimiter)) { + delimiter = makeHeredocDelimiter(); + } + return `${key}<<${delimiter}\n${value.join('\n')}\n${delimiter}`; } return `${key}=${value}`; } @@ -416,6 +461,7 @@ function printUsage() { ' --files Read newline-delimited file list from a path', ' or stdin (`-`); skip git entirely.', ' --include-files Include the matching file paths per bucket in the output.', + ' --strict Exit 1 when git diff fails instead of reporting no changes.', ' --dry-run With --output github, preview to stdout (no file write).', ' --help, -h Print this help.', '', @@ -427,7 +473,7 @@ function printUsage() { * Run the CLI. Returns the intended exit code. * * @param {string[]} argv argv slice (without `node` and script path). - * @return {number} Process exit code (0 on success, 1 on I/O failure, 2 on usage error). + * @return {number} Process exit code (0 on success, 1 on I/O failure or a strict-mode diff failure, 2 on usage error). */ function runCli(argv) { let opts; @@ -473,12 +519,24 @@ function runCli(argv) { } } - const result = detectChanges({ - files, - ignore: opts.ignore, - base: opts.base, - includeFiles: opts.includeFiles === true, - }); + let result; + try { + result = detectChanges({ + files, + ignore: opts.ignore, + base: opts.base, + includeFiles: opts.includeFiles === true, + strict: opts.strict === true, + }); + } catch (err) { + if (err instanceof DetectChangesError) { + process.stderr.write( + `detect-changes: ${err.message}. Failing because --strict is set.\n` + ); + return 1; + } + throw err; + } emit(result, opts.output, opts.dryRun === true); return 0; @@ -489,6 +547,7 @@ module.exports = { runCli, takeValue, formatGithubLine, + DetectChangesError, DEFAULT_PATTERNS, DEFAULT_IGNORE, }; diff --git a/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js b/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js new file mode 100644 index 0000000..263af7a --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js @@ -0,0 +1,338 @@ +'use strict'; + +/** + * detect-changes against real git repositories: how the diff base is resolved + * in pull request and push mode, what a failed diff does with and without + * --strict, and that a crafted file list cannot forge a step output. + */ + +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + detectChanges, + runCli, + formatGithubLine, + DetectChangesError, +} = require('../../src/ci/detect-changes'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + GIT_CONFIG_NOSYSTEM: '1', +}; + +const ENV_KEYS = ['GITHUB_BASE_REF', 'GITHUB_EVENT_PATH', 'GITHUB_OUTPUT']; + +const tempDirs = []; +let savedEnv; +let savedCwd; +let stderrChunks; + +function git(cwd, ...args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, ...GIT_ENV }, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function makeTmpDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function commitFile(repo, file, body, message) { + fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true }); + fs.writeFileSync(path.join(repo, file), body); + git(repo, 'add', '--', file); + git(repo, 'commit', '-q', '-m', message); + return git(repo, 'rev-parse', 'HEAD'); +} + +/** + * A working repo with an `origin` remote (a local bare repo) holding `main`, + * and a `feature` branch checked out one commit ahead of it. + */ +function makePullRequestRepo() { + const remote = makeTmpDir('dc-remote-'); + git(remote, 'init', '-q', '--bare', '-b', 'main'); + const repo = makeTmpDir('dc-repo-'); + git(repo, 'init', '-q', '-b', 'main'); + commitFile(repo, 'README.md', 'base\n', 'base'); + git(repo, 'remote', 'add', 'origin', remote); + git(repo, 'push', '-q', 'origin', 'main'); + git(repo, 'checkout', '-q', '-b', 'feature'); + commitFile(repo, 'src/feature.js', 'module.exports = 1;\n', 'feature js'); + commitFile(repo, 'inc/Feature.php', '} Outputs. + */ +function parseGithubOutput(text) { + const out = {}; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const heredoc = /^([^=<]+)<<(.+)$/.exec(line); + if (heredoc) { + const [, key, delimiter] = heredoc; + const body = []; + i++; + while (i < lines.length && lines[i] !== delimiter) { + body.push(lines[i]); + i++; + } + out[key] = body.join('\n'); + continue; + } + const eq = line.indexOf('='); + if (eq > 0) { + out[line.slice(0, eq)] = line.slice(eq + 1); + } + } + return out; +} + +beforeEach(() => { + savedCwd = process.cwd(); + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + stderrChunks = []; + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrChunks.push(String(chunk)); + return true; + }); + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + process.chdir(savedCwd); + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('detectChanges against a git repository', () => { + test('pull request mode diffs HEAD against origin/', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'main'; + + const result = detectChanges({ includeFiles: true }); + + expect(result['total-files']).toEqual([ + 'inc/Feature.php', + 'src/feature.js', + ]); + expect(result['php-count']).toBe(1); + expect(result['js-count']).toBe(1); + }); + + test('push mode diffs against the event payload before SHA', () => { + const repo = makeTmpDir('dc-push-'); + git(repo, 'init', '-q', '-b', 'main'); + const before = commitFile(repo, 'README.md', 'one\n', 'one'); + commitFile(repo, 'style.scss', 'a{}\n', 'two'); + commitFile(repo, 'plugin.php', ' { + const repo = makeTmpDir('dc-newbranch-'); + git(repo, 'init', '-q', '-b', 'main'); + commitFile(repo, 'README.md', 'one\n', 'one'); + commitFile(repo, 'a.js', '1;\n', 'two'); + commitFile(repo, 'b.php', ' { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'does-not-exist'; + const base = git(repo, 'rev-parse', 'HEAD~1'); + + const result = detectChanges({ base, includeFiles: true }); + + expect(result['total-files']).toEqual(['inc/Feature.php']); + }); + + test('a failed diff is soft by default: no changes plus a stderr note', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + + const result = detectChanges({ base: 'no-such-ref' }); + + expect(result['total-count']).toBe(0); + expect(stderrChunks.join('')).toMatch( + /git diff failed against "no-such-ref"[\s\S]*Treating as no changes/ + ); + }); + + test('strict mode throws DetectChangesError instead', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + + let thrown; + try { + detectChanges({ base: 'no-such-ref', strict: true }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(DetectChangesError); + expect(thrown.code).toBe('EDIFFFAIL'); + expect(thrown.base).toBe('no-such-ref'); + expect(thrown.message).toMatch(/git diff failed against "no-such-ref"/); + }); + + test('a pull request base that cannot be fetched fails strict mode', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'gone'; + + expect(() => detectChanges({ strict: true })).toThrow( + DetectChangesError + ); + expect(stderrChunks.join('')).toMatch(/git fetch origin gone failed/); + }); +}); + +describe('runCli --strict', () => { + test('exits 1, writes no outputs and keeps the "git diff failed" wording', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + const outPath = path.join(makeTmpDir('dc-out-'), 'output'); + fs.writeFileSync(outPath, ''); + process.env.GITHUB_OUTPUT = outPath; + + const code = runCli([ + '--strict', + '--base', + 'no-such-ref', + '--output', + 'github', + ]); + + expect(code).toBe(1); + expect(fs.readFileSync(outPath, 'utf8')).toBe(''); + // rtCamp/wp-shared-workflows greps for this phrase; keep it stable. + expect(stderrChunks.join('')).toMatch( + /git diff failed against "no-such-ref"[\s\S]*--strict/ + ); + }); + + test('passes through normally when the diff works', () => { + const repo = makePullRequestRepo(); + process.chdir(repo); + process.env.GITHUB_BASE_REF = 'main'; + const outPath = path.join(makeTmpDir('dc-out-'), 'output'); + fs.writeFileSync(outPath, ''); + process.env.GITHUB_OUTPUT = outPath; + + expect(runCli(['--strict', '--output', 'github'])).toBe(0); + expect( + parseGithubOutput(fs.readFileSync(outPath, 'utf8')) + ).toMatchObject({ + 'total-count': '2', + 'php-count': '1', + 'js-count': '1', + }); + }); +}); + +describe('GitHub output heredocs', () => { + test('a crafted file list cannot close the heredoc and forge a count', () => { + const list = path.join(makeTmpDir('dc-list-'), 'files.txt'); + // The old fixed delimiter, then a file whose name is a forged output. + fs.writeFileSync( + list, + ['inc/Plugin.php', 'EOF_WP_TOOLING', 'php-count=0', ''].join('\n') + ); + const outPath = path.join(makeTmpDir('dc-out-'), 'output'); + fs.writeFileSync(outPath, ''); + process.env.GITHUB_OUTPUT = outPath; + + expect( + runCli(['--files', list, '--include-files', '--output', 'github']) + ).toBe(0); + const outputs = parseGithubOutput(fs.readFileSync(outPath, 'utf8')); + + expect(outputs['php-count']).toBe('1'); + expect(outputs['total-files'].split('\n')).toEqual([ + 'inc/Plugin.php', + 'EOF_WP_TOOLING', + 'php-count=0', + ]); + }); + + test('every multi-line output gets its own random delimiter', () => { + const first = formatGithubLine('a-files', ['x.php']); + const second = formatGithubLine('a-files', ['x.php']); + + const delimiter = (block) => + /<<(ghadelim_[0-9a-f]{32})\n/.exec(block)[1]; + expect(delimiter(first)).not.toBe(delimiter(second)); + }); + + test('draws a new delimiter when a value line equals the first one', () => { + const clash = Buffer.alloc(16, 0xab); + const fresh = Buffer.alloc(16, 0xcd); + jest.spyOn(crypto, 'randomBytes') + .mockImplementationOnce(() => clash) + .mockImplementationOnce(() => fresh); + const clashing = `ghadelim_${clash.toString('hex')}`; + + const block = formatGithubLine('x-files', [clashing, 'b.php']); + + expect(block).toBe( + `x-files< { + expect(formatGithubLine('php-count', 3)).toBe('php-count=3'); + expect(formatGithubLine('php-files', [])).toBe('php-files='); + }); +}); diff --git a/node-packages/wp-tooling/tests/ci/detect-changes.test.js b/node-packages/wp-tooling/tests/ci/detect-changes.test.js index b48ffea..8e9aa42 100644 --- a/node-packages/wp-tooling/tests/ci/detect-changes.test.js +++ b/node-packages/wp-tooling/tests/ci/detect-changes.test.js @@ -543,10 +543,10 @@ describe('runCli', () => { const written = fs.readFileSync(outPath, 'utf8'); expect(written).toMatch(/total-count=2/); expect(written).toMatch( - /js-files< { const out = stdoutChunks.join(''); expect(out).toMatch(/\[dry-run\] would append/); expect(out).toMatch( - /js-files< Date: Thu, 1 Oct 2026 06:47:36 +0530 Subject: [PATCH 07/15] fix(scaffolds): install the @rtcamp config packages from git, not npm lint/eslint, lint/stylelint and setup/tailwind reported @rtcamp/eslint-config, @rtcamp/stylelint-config and @rtcamp/tailwind-config at ^0.1.0. None of the three is on the npm registry (each returns 404, and the packages are at 1.0.0), so the reported install failed with E404, and features --enable tailwind --record-deps wrote that spec into package.json, breaking the project's next npm install. Use the git specs the theme and plugin skeletons already install from (github:rtCamp/wp-tooling#npm/). Verified in a scratch project: the ^0.1.0 spec fails with E404 and the git spec installs. --- node-packages/wp-tooling/CHANGELOG.md | 2 ++ node-packages/wp-tooling/docs/examples.md | 2 +- node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json | 2 +- node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json | 2 +- .../scaffolds/setup/claude-skills/templates/setup-SKILL.md | 2 +- node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json | 2 +- node-packages/wp-tooling/skills/setup/SKILL.md | 2 +- 7 files changed, 8 insertions(+), 6 deletions(-) diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index e21b437..4554eb1 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -44,6 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly. - Generated block JavaScript follows the current shared ESLint formatting rules. - PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics. +- `lint/eslint`, `lint/stylelint` and `setup/tailwind` asked for `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` at `^0.1.0` from the npm registry. None of them is published there (all three 404, and the packages are at 1.0.0), so `npm install` failed, and `features --enable tailwind --record-deps` wrote a spec into package.json that broke the project's next `npm install`. They now use the git specs the skeletons already use (`github:rtCamp/wp-tooling#npm/`). +- `lint/phpcs/full` now extends `rtCampWP-Basic` from `rtcamp/wp-phpcs` (and reports it plus `dealerdirect/phpcodesniffer-composer-installer` as dev dependencies) instead of `vendor/rtcamp/wp-framework/phpcs.xml.dist`. The framework export-ignores that file, so every dist install, the Composer default, ended up with a ruleset pointing at a file that was not there. The generated ruleset also sets `testVersion` to `8.2-`, which PHPCompatibilityWP needs. - `detect-changes --output github` and `coverage-gate --output github` write multi-line outputs under a random per-output heredoc delimiter instead of the fixed `EOF_WP_TOOLING`. The file lists come from the pull request, so a PR adding a file named `EOF_WP_TOOLING` followed by one named `php-count=0` could close the heredoc early and override the real count, skipping the PHP jobs. - `discover_from: composer.json:autoload.psr-4` now resolves **path** inputs (`base_path`, `*_path`, `*_dir`) from the map entry's directory instead of skipping them, grafted the same way namespaces already were — with a root of `Acme\Blog\` → `inc/`, a `base_path` default of `includes/Services` resolves to `inc/Services`. Previously the namespace was grafted but the directory kept the manifest default, so any project not laid out under `includes/` got a correctly-namespaced class written outside its autoload root, where it never loaded. Affects the 12 bundled scaffolds that declare PSR-4 discovery on `base_path`. Projects already using `includes/`, projects without a `composer.json`, and callers that pass `--base_path` explicitly are unaffected. - Feature finalization rolls back successful hooks when detection or state persistence fails, and defers next-step messages until persistence succeeds. Setup/reinit identity and version writes share rollback protection. Rename batches reuse one plan, support chains and swaps, and detect case-insensitive destination collisions. Cleanup targets and explicit example markers reject malformed values before mutation. diff --git a/node-packages/wp-tooling/docs/examples.md b/node-packages/wp-tooling/docs/examples.md index f75969d..93b18fd 100644 --- a/node-packages/wp-tooling/docs/examples.md +++ b/node-packages/wp-tooling/docs/examples.md @@ -97,7 +97,7 @@ Developer actions: composer require rtcamp/wp-primitives:^2.0 composer require --dev rtcamp/wp-phpcs:^1.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 - npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^0.1.0 + npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@github:rtCamp/wp-tooling#npm/eslint-config Scripts to add to composer.json: "lint:php": "phpcs --standard=phpcs.xml.dist", "test": "phpunit", ... diff --git a/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json index 761f93b..9046df9 100644 --- a/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/eslint/scaffold.json @@ -13,7 +13,7 @@ "npm_dev_dependencies": { "eslint": "^10.0.0", "@wordpress/eslint-plugin": "^25.1.0", - "@rtcamp/eslint-config": "^0.1.0" + "@rtcamp/eslint-config": "github:rtCamp/wp-tooling#npm/eslint-config" }, "scripts": { "npm": { diff --git a/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json index 79c3009..1199949 100644 --- a/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/stylelint/scaffold.json @@ -14,7 +14,7 @@ "stylelint": "^16.8.2", "@wordpress/stylelint-config": "^23.37.0", "stylelint-scss": "^6.4.0", - "@rtcamp/stylelint-config": "^0.1.0" + "@rtcamp/stylelint-config": "github:rtCamp/wp-tooling#npm/stylelint-config" }, "scripts": { "npm": { diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md index a230cc0..8f8a4e6 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md @@ -315,7 +315,7 @@ Developer actions (run these yourself): npm install --save-dev \ eslint@^10.0.0 \ @wordpress/eslint-plugin@^25.1.0 \ - @rtcamp/eslint-config@^0.1.0 + @rtcamp/eslint-config@github:rtCamp/wp-tooling#npm/eslint-config Scripts to add to composer.json: diff --git a/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json index 9292d26..e0a7d3d 100644 --- a/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/setup/tailwind/scaffold.json @@ -29,7 +29,7 @@ "gitignore": [ "{{css_dir}}/_tailwind-theme.css" ] }, "npm_dev_dependencies": { - "@rtcamp/tailwind-config": "^0.1.0", + "@rtcamp/tailwind-config": "github:rtCamp/wp-tooling#npm/tailwind-config", "tailwindcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0" } diff --git a/node-packages/wp-tooling/skills/setup/SKILL.md b/node-packages/wp-tooling/skills/setup/SKILL.md index a230cc0..8f8a4e6 100644 --- a/node-packages/wp-tooling/skills/setup/SKILL.md +++ b/node-packages/wp-tooling/skills/setup/SKILL.md @@ -315,7 +315,7 @@ Developer actions (run these yourself): npm install --save-dev \ eslint@^10.0.0 \ @wordpress/eslint-plugin@^25.1.0 \ - @rtcamp/eslint-config@^0.1.0 + @rtcamp/eslint-config@github:rtCamp/wp-tooling#npm/eslint-config Scripts to add to composer.json: From e439903f267fd68d4a3e9f9728e12df2889608d6 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:50:48 +0530 Subject: [PATCH 08/15] test(features): cover the features CLI end to end and fix two report bugs The features command (also embedded by the theme init through runFeatures) had 38% line coverage: nothing exercised argument parsing, status output, the human report, the interactive flow or the JSON error path. The new suites run it against the bundled Tailwind feature in throwaway projects (status, enable, --record-deps, --dry-run, disable with and without --force, unknown ids, prompts, cancellation, a failing change) and against a mocked npm for the install success and failure reports. They found two bugs, fixed here: - after a failed npm install, the retry commands put runtime dependencies under --save-dev; - a failed interactive change read 'Failed to enabling '. --- node-packages/wp-tooling/CHANGELOG.md | 1 + .../wp-tooling/src/scaffolds/features.js | 8 +- .../scaffolds/features-cli-install.test.js | 107 +++++ .../tests/scaffolds/features-cli.test.js | 382 ++++++++++++++++++ 4 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 node-packages/wp-tooling/tests/scaffolds/features-cli-install.test.js create mode 100644 node-packages/wp-tooling/tests/scaffolds/features-cli.test.js diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 4554eb1..9055c2b 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -44,6 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly. - Generated block JavaScript follows the current shared ESLint formatting rules. - PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics. +- `wp-tooling features`: when `npm install` fails after enabling a feature, the retry commands it prints no longer put runtime dependencies under `--save-dev`, and a failed interactive change reads "Failed to enable " rather than "Failed to enabling ". - `lint/eslint`, `lint/stylelint` and `setup/tailwind` asked for `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` at `^0.1.0` from the npm registry. None of them is published there (all three 404, and the packages are at 1.0.0), so `npm install` failed, and `features --enable tailwind --record-deps` wrote a spec into package.json that broke the project's next `npm install`. They now use the git specs the skeletons already use (`github:rtCamp/wp-tooling#npm/`). - `lint/phpcs/full` now extends `rtCampWP-Basic` from `rtcamp/wp-phpcs` (and reports it plus `dealerdirect/phpcodesniffer-composer-installer` as dev dependencies) instead of `vendor/rtcamp/wp-framework/phpcs.xml.dist`. The framework export-ignores that file, so every dist install, the Composer default, ended up with a ruleset pointing at a file that was not there. The generated ruleset also sets `testVersion` to `8.2-`, which PHPCompatibilityWP needs. - `detect-changes --output github` and `coverage-gate --output github` write multi-line outputs under a random per-output heredoc delimiter instead of the fixed `EOF_WP_TOOLING`. The file lists come from the pull request, so a PR adding a file named `EOF_WP_TOOLING` followed by one named `php-count=0` could close the heredoc early and override the real count, skipping the PHP jobs. diff --git a/node-packages/wp-tooling/src/scaffolds/features.js b/node-packages/wp-tooling/src/scaffolds/features.js index 5a00db2..92d5400 100644 --- a/node-packages/wp-tooling/src/scaffolds/features.js +++ b/node-packages/wp-tooling/src/scaffolds/features.js @@ -359,6 +359,7 @@ async function runInteractive(opts) { const summaries = []; for (const change of changes) { const verb = change.target ? 'Enabling' : 'Disabling'; + const action = change.target ? 'enable' : 'disable'; const s = spinner(`${verb} ${change.id}...`); s.start(); try { @@ -369,7 +370,7 @@ async function runInteractive(opts) { s.succeed(`${summary.action} ${change.id}`); summaries.push(summary); } catch (err) { - s.fail(`Failed to ${verb.toLowerCase()} ${change.id}`); + s.fail(`Failed to ${action} ${change.id}`); throw err; } } @@ -461,7 +462,10 @@ function printHumanReport(summaries, opts) { writeln( ' re-run the install yourself once the cause is fixed:' ); - for (const [p, v] of [...prod, ...dev]) { + for (const [p, v] of prod) { + writeln(` npm install ${p}@${v}`); + } + for (const [p, v] of dev) { writeln(` npm install --save-dev ${p}@${v}`); } } else if (s.installed) { diff --git a/node-packages/wp-tooling/tests/scaffolds/features-cli-install.test.js b/node-packages/wp-tooling/tests/scaffolds/features-cli-install.test.js new file mode 100644 index 0000000..b28770a --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/features-cli-install.test.js @@ -0,0 +1,107 @@ +/** + * `wp-tooling features --enable` when npm runs and fails: the feature stays + * enabled, the warning carries npm's stderr, and the retry commands put + * runtime deps in `dependencies` and dev deps in `devDependencies`. + * + * Own file so child_process can be mocked before features.js captures + * execFileSync. + */ + +'use strict'; + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFileSync: jest.fn(), +})); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { execFileSync } = require('child_process'); +const { runCli } = require('../../src/scaffolds/features'); + +const tempDirs = []; +let stdout; + +function projectWithFeature() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-fi-')); + tempDirs.push(dir); + const scaffold = path.join(dir, 'bin', 'scaffolds', 'setup', 'charts'); + fs.mkdirSync(path.join(scaffold, 'templates'), { recursive: true }); + fs.writeFileSync(path.join(scaffold, 'templates', 'c.js'), 'x\n', 'utf8'); + fs.writeFileSync( + path.join(scaffold, 'scaffold.json'), + JSON.stringify({ + slug: 'charts', + category: 'setup', + name: 'Charts', + description: 'Charting library.', + source: 'template', + files: [{ src: 'templates/c.js', dest: 'charts.js', raw: true }], + feature: { config_key: 'charts' }, + npm_dependencies: { 'chart-runtime': '^2.0.0' }, + npm_dev_dependencies: { 'chart-types': '^1.0.0' }, + }), + 'utf8' + ); + return dir; +} + +beforeEach(() => { + stdout = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + execFileSync.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a failed install keeps the feature enabled and prints correct retry commands', async () => { + const dir = projectWithFeature(); + const err = new Error('Command failed: npm install'); + err.stderr = 'npm ERR! code E404\nnpm ERR! 404 Not Found - chart-runtime'; + execFileSync.mockImplementation(() => { + throw err; + }); + + expect(await runCli(['--enable', 'charts', '--cwd', dir])).toBe(0); + + const out = stdout.join(''); + expect(out).toMatch(/enabled: charts/); + expect(out).toMatch(/! enabled, but npm install failed:/); + expect(out).toMatch(/404 Not Found - chart-runtime/); + expect(out).toMatch(/\n {4}npm install chart-runtime@\^2\.0\.0\n/); + expect(out).toMatch(/\n {4}npm install --save-dev chart-types@\^1\.0\.0\n/); + expect(out).not.toMatch(/--save-dev chart-runtime/); + expect(fs.existsSync(path.join(dir, 'charts.js'))).toBe(true); +}); + +test('a successful install lists what it installed', async () => { + const dir = projectWithFeature(); + execFileSync.mockImplementation(() => Buffer.from('')); + + expect(await runCli(['--enable', 'charts', '--cwd', dir])).toBe(0); + + expect(execFileSync).toHaveBeenCalledWith( + 'npm', + ['install', '--save', 'chart-runtime@^2.0.0'], + expect.objectContaining({ cwd: path.resolve(dir) }) + ); + expect(execFileSync).toHaveBeenCalledWith( + 'npm', + ['install', '--save-dev', 'chart-types@^1.0.0'], + expect.objectContaining({ cwd: path.resolve(dir) }) + ); + const out = stdout.join(''); + expect(out).toMatch(/installed chart-runtime@\^2\.0\.0/); + expect(out).toMatch(/installed chart-types@\^1\.0\.0/); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/features-cli.test.js b/node-packages/wp-tooling/tests/scaffolds/features-cli.test.js new file mode 100644 index 0000000..5c45e0c --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/features-cli.test.js @@ -0,0 +1,382 @@ +/** + * `wp-tooling features` end to end: the CLI and the programmatic entry point + * (theme-elementary's init embeds runFeatures) against the bundled Tailwind + * feature, in throwaway projects. npm is never run (`--no-install` or + * `install: false`); the interactive flow runs against a mocked TTY kit. + */ + +'use strict'; + +jest.mock('../../src/ui', () => ({ + confirm: jest.fn(), + spinner: jest.fn(), +})); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ui = require('../../src/ui'); +const { runCli, runFeatures } = require('../../src/scaffolds/features'); + +const tempDirs = []; +let stdout; +let stderr; + +function makeProject({ packageJson = true } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-features-')); + tempDirs.push(dir); + if (packageJson) { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'demo', devDependencies: {} }, null, '\t') + + '\n', + 'utf8' + ); + } + return dir; +} + +function readConfig(dir) { + return JSON.parse( + fs.readFileSync(path.join(dir, '.wp-tooling.json'), 'utf8') + ); +} + +// The developer-owned Tailwind entry the feature asks before deleting. +function writeEntryCss(dir) { + const css = path.join(dir, 'src', 'css', 'frontend', 'tailwind.css'); + fs.mkdirSync(path.dirname(css), { recursive: true }); + fs.writeFileSync(css, '@import "tailwindcss";\n', 'utf8'); + return css; +} + +function lastJson(chunks) { + return JSON.parse(chunks.join('').trim().split('\n').pop()); +} + +beforeEach(() => { + stdout = []; + stderr = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + ui.confirm.mockReset(); + ui.spinner.mockReset(); + ui.spinner.mockImplementation(() => ({ + start: jest.fn(), + update: jest.fn(), + succeed: jest.fn(), + fail: jest.fn(), + })); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('runCli: help and argument errors', () => { + test('--help prints usage and exits 0', async () => { + expect(await runCli(['--help'])).toBe(0); + expect(stdout.join('')).toMatch(/Usage: wp-tooling features/); + }); + + test('an unknown argument exits 1 with the usage', async () => { + expect(await runCli(['--nope'])).toBe(1); + expect(stderr.join('')).toMatch(/Unexpected argument: --nope/); + expect(stdout.join('')).toMatch(/Usage: wp-tooling features/); + }); + + test('a flag missing its value exits 1 instead of swallowing the next flag', async () => { + expect(await runCli(['--cwd', '--json'])).toBe(1); + expect(stderr.join('')).toMatch(/--cwd/); + }); +}); + +describe('runCli: status', () => { + test('--json lists the bundled Tailwind feature as off', async () => { + const dir = makeProject(); + expect(await runCli(['--json', '--cwd', dir])).toBe(0); + const { features } = lastJson(stdout); + const tailwind = features.find((f) => f.id === 'setup/tailwind'); + expect(tailwind).toMatchObject({ + slug: 'tailwind', + configKey: 'tailwind', + enabled: false, + }); + }); + + test('--non-interactive prints a checklist', async () => { + const dir = makeProject(); + expect(await runCli(['--non-interactive', `--cwd=${dir}`])).toBe(0); + const out = stdout.join(''); + expect(out).toMatch(/Features \(\d+\):/); + expect(out).toMatch(/\[ \] setup\/tailwind/); + }); +}); + +describe('runCli: enable and disable', () => { + test('--enable --no-install --json writes files, sets the flag and reports deps', async () => { + const dir = makeProject(); + const code = await runCli([ + '--enable', + 'tailwind', + '--no-install', + '--json', + '--cwd', + dir, + ]); + expect(code).toBe(0); + const { changes } = lastJson(stdout); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + id: 'tailwind', + action: 'enabled', + installed: false, + recorded: [], + }); + expect(changes[0].wrote).toContain('postcss.config.js'); + expect(changes[0].install.npmDev).toHaveProperty('tailwindcss'); + expect(fs.existsSync(path.join(dir, 'postcss.config.js'))).toBe(true); + expect(readConfig(dir).features.tailwind).toBe(true); + // Plain --no-install reports only: package.json is untouched. + expect( + JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) + .devDependencies + ).toEqual({}); + }); + + test('the human report suggests the install commands when nothing was installed', async () => { + const dir = makeProject(); + await runCli(['--enable=tailwind', '--no-install', '--cwd', dir]); + const out = stdout.join(''); + expect(out).toMatch(/enabled: tailwind\n/); + expect(out).toMatch(/\+ postcss\.config\.js/); + expect(out).toMatch(/npm install --save-dev tailwindcss@/); + expect(out).toMatch(/Done\./); + }); + + test('--record-deps records the deps in package.json and says so', async () => { + const dir = makeProject(); + await runCli([ + '--enable', + 'tailwind', + '--no-install', + '--record-deps', + '--cwd', + dir, + ]); + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ); + expect(Object.keys(pkg.devDependencies)).toEqual( + expect.arrayContaining(['tailwindcss', '@tailwindcss/postcss']) + ); + const out = stdout.join(''); + expect(out).toMatch(/recorded tailwindcss@.* in package\.json/); + expect(out).toMatch(/run `npm install` to install the recorded deps/); + }); + + test('--dry-run reports the change without writing anything', async () => { + const dir = makeProject(); + await runCli([ + '--enable', + 'tailwind', + '--dry-run', + '--no-install', + '--cwd', + dir, + ]); + expect(stdout.join('')).toMatch(/enabled: tailwind \(dry run\)/); + expect(fs.existsSync(path.join(dir, 'postcss.config.js'))).toBe(false); + expect(fs.existsSync(path.join(dir, '.wp-tooling.json'))).toBe(false); + }); + + test('--disable without --force keeps the developer-editable entry CSS', async () => { + const dir = makeProject(); + await runCli(['--enable', 'tailwind', '--no-install', '--cwd', dir]); + const css = writeEntryCss(dir); + stdout.length = 0; + + expect( + await runCli(['--disable', 'setup/tailwind', '--cwd', dir]) + ).toBe(0); + + const out = stdout.join(''); + expect(out).toMatch(/disabled: setup\/tailwind/); + expect(out).toMatch(/- postcss\.config\.js/); + expect(out).toMatch(/= kept .*tailwind\.css/); + expect(fs.existsSync(path.join(dir, 'postcss.config.js'))).toBe(false); + expect(fs.existsSync(css)).toBe(true); + expect(readConfig(dir).features.tailwind).toBeFalsy(); + }); + + test('--disable --force also removes the developer-editable files', async () => { + const dir = makeProject(); + await runCli(['--enable', 'tailwind', '--no-install', '--cwd', dir]); + const css = writeEntryCss(dir); + stdout.length = 0; + + await runCli([ + '--disable', + 'tailwind', + '--force', + '--json', + '--cwd', + dir, + ]); + + const { changes } = lastJson(stdout); + expect(changes[0].action).toBe('disabled'); + expect(changes[0].kept).toEqual([]); + expect(changes[0].removed.join(' ')).toMatch(/tailwind\.css/); + expect(fs.existsSync(css)).toBe(false); + }); + + test('an unknown feature id fails with ENOTFEATURE as JSON on stderr', async () => { + const dir = makeProject(); + expect( + await runCli([ + '--enable', + 'no-such-feature', + '--json', + '--cwd', + dir, + ]) + ).toBe(1); + expect(JSON.parse(stderr.join('').trim())).toMatchObject({ + code: 'ENOTFEATURE', + }); + }); + + test('an unknown feature id fails with a plain message without --json', async () => { + const dir = makeProject(); + expect( + await runCli(['--disable', 'no-such-feature', '--cwd', dir]) + ).toBe(1); + expect(stderr.join('')).toMatch( + /Error: No toggleable feature: no-such-feature/ + ); + }); +}); + +describe('runCli: interactive', () => { + test('applies a change the developer confirms, under a spinner', async () => { + const dir = makeProject(); + ui.confirm.mockImplementation(async ({ message }) => + /Tailwind/.test(message) + ); + + expect(await runCli(['--no-install', '--cwd', dir])).toBe(0); + + expect(ui.confirm).toHaveBeenCalledWith( + expect.objectContaining({ defaultValue: false }) + ); + const spin = ui.spinner.mock.results[0].value; + expect(spin.start).toHaveBeenCalled(); + expect(spin.succeed).toHaveBeenCalledWith('enabled setup/tailwind'); + expect(readConfig(dir).features.tailwind).toBe(true); + }); + + test('says "No changes." when every answer keeps the current state', async () => { + const dir = makeProject(); + ui.confirm.mockImplementation(async ({ defaultValue }) => defaultValue); + + expect(await runCli(['--cwd', dir])).toBe(0); + expect(stdout.join('')).toMatch(/No changes\./); + expect(ui.spinner).not.toHaveBeenCalled(); + }); + + test('asks before deleting a developer-editable file on disable', async () => { + const dir = makeProject(); + await runCli(['--enable', 'tailwind', '--no-install', '--cwd', dir]); + const css = writeEntryCss(dir); + const asked = []; + ui.confirm.mockImplementation(async ({ message, defaultValue }) => { + asked.push(message); + if (/Delete developer-editable file/.test(message)) { + return true; + } + // Flip Tailwind off; keep anything else as it is. + return /Tailwind/.test(message) ? false : defaultValue; + }); + + expect(await runCli(['--cwd', dir])).toBe(0); + + expect(asked.some((m) => /currently on/.test(m))).toBe(true); + expect( + asked.some((m) => /Delete developer-editable file/.test(m)) + ).toBe(true); + expect(fs.existsSync(css)).toBe(false); + expect(readConfig(dir).features.tailwind).toBeFalsy(); + }); + + test('a cancelled prompt propagates so the dispatcher can exit 130', async () => { + const dir = makeProject(); + const cancelled = new Error('cancelled'); + cancelled.name = 'CancelledError'; + ui.confirm.mockRejectedValue(cancelled); + + await expect(runCli(['--cwd', dir])).rejects.toBe(cancelled); + }); + + test('a failing change marks the spinner failed and exits 1', async () => { + const dir = makeProject(); + // A file where a directory has to go makes the gitignore write fail. + fs.mkdirSync(path.join(dir, '.gitignore')); + ui.confirm.mockImplementation(async ({ message }) => + /Tailwind/.test(message) + ); + + expect(await runCli(['--no-install', '--cwd', dir])).toBe(1); + + const spin = ui.spinner.mock.results[0].value; + expect(spin.fail).toHaveBeenCalledWith( + 'Failed to enable setup/tailwind' + ); + expect(stderr.join('')).toMatch(/^Error: /); + }); +}); + +describe('runFeatures (programmatic, as embedded by the theme init)', () => { + test('enable implies non-interactive and never prompts', async () => { + const dir = makeProject(); + expect( + await runFeatures({ + cwd: dir, + enable: ['tailwind'], + install: false, + }) + ).toBe(0); + expect(ui.confirm).not.toHaveBeenCalled(); + expect(readConfig(dir).features.tailwind).toBe(true); + }); + + test('install:false with record:true records deps, as init does inside npm install', async () => { + const dir = makeProject(); + await runFeatures({ + cwd: dir, + enable: ['tailwind'], + install: false, + record: true, + }); + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ); + expect(pkg.devDependencies).toHaveProperty('tailwindcss'); + }); + + test('with no changes requested it prints the status', async () => { + const dir = makeProject({ packageJson: false }); + expect(await runFeatures({ cwd: dir, nonInteractive: true })).toBe(0); + expect(stdout.join('')).toMatch(/setup\/tailwind/); + }); +}); From 5a2533cce6e936777002147fbd0d25fa1751f5dc Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:52:25 +0530 Subject: [PATCH 09/15] test(add): cover the human report sections and the interactive wizard The add command sat at 58% line coverage: the interactive wizard (prompting for missing inputs, confirm, spinner, cancel, dry run) and most human report sections (composer installs, secrets, wiring, scaffolded tests, skipped files, warnings, the available-scaffolds list for an unknown id) never ran in tests. The new suite drives them with the real Wizard and stubbed prompts. --- .../tests/scaffolds/add-cli.test.js | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 node-packages/wp-tooling/tests/scaffolds/add-cli.test.js diff --git a/node-packages/wp-tooling/tests/scaffolds/add-cli.test.js b/node-packages/wp-tooling/tests/scaffolds/add-cli.test.js new file mode 100644 index 0000000..cdf1f50 --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/add-cli.test.js @@ -0,0 +1,301 @@ +/** + * `wp-tooling add`: the human report sections the existing suite does not + * reach (composer installs, secrets, wiring, scaffolded tests, warnings, + * skipped files) and the interactive wizard flow (prompting for missing + * inputs, confirm, spinner, cancel, dry run, unknown id). + * + * The real Wizard runs; only the prompts and the spinner are stubbed. + */ + +'use strict'; + +jest.mock('../../src/ui', () => { + const actual = jest.requireActual('../../src/ui'); + return { + ...actual, + confirm: jest.fn(), + spinner: jest.fn(), + }; +}); +jest.mock('../../src/scaffolds/prompt-inputs', () => ({ + promptMissingInputs: jest.fn(), +})); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ui = require('../../src/ui'); +const { promptMissingInputs } = require('../../src/scaffolds/prompt-inputs'); +const { runCli } = require('../../src/scaffolds/add'); + +const tempDirs = []; +let stdout; +let stderr; + +function makeProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-add-cli-')); + tempDirs.push(dir); + fs.writeFileSync( + path.join(dir, 'composer.json'), + JSON.stringify({ + name: 'acme/shop', + autoload: { 'psr-4': { 'Acme\\Shop\\': 'includes/' } }, + }), + 'utf8' + ); + return dir; +} + +beforeEach(() => { + stdout = []; + stderr = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + ui.confirm.mockReset(); + ui.spinner.mockReset(); + ui.spinner.mockImplementation(() => ({ + start: jest.fn(), + update: jest.fn(), + succeed: jest.fn(), + fail: jest.fn(), + })); + promptMissingInputs.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('human report (non-interactive)', () => { + test('lists the written files, composer install, wiring and scaffolded tests', async () => { + const dir = makeProject(); + const code = await runCli([ + 'wp/cpt', + '--non-interactive', + '--slug', + 'product', + '--singular', + 'Product', + '--plural', + 'Products', + '--cwd', + dir, + ]); + + expect(code).toBe(0); + const out = stdout.join(''); + expect(out).toMatch(/^Scaffold: wp\/cpt, kind: template/); + expect(out).toMatch( + /Engine wrote:\n {2}\+ includes\/PostTypes\/Product\.php/ + ); + expect(out).toMatch( + /Install \(composer\):\n {4}composer require rtcamp\// + ); + expect(out).toMatch(/Wiring suggestions/); + expect(out).toMatch(/File: includes\/Modules\/PostTypes\.php/); + expect(out).toMatch( + /Insert near anchor: \/\/ scaffold:wp\/cpt:classes/ + ); + expect(out).toMatch( + /Tests scaffolded:\n {2}\+ tests\/PostTypes\/ProductTest\.php \(phpunit/ + ); + expect(out.trim().endsWith('Done.')).toBe(true); + }); + + test('reports files that already existed as skipped, with a warning', async () => { + const dir = makeProject(); + const args = [ + 'wp/cpt', + '--non-interactive', + '--slug', + 'product', + '--singular', + 'Product', + '--plural', + 'Products', + '--cwd', + dir, + ]; + await runCli(args); + stdout.length = 0; + + expect(await runCli(args)).toBe(0); + + const out = stdout.join(''); + expect(out).toMatch(/Engine wrote: \(no files\)/); + expect(out).toMatch( + /Engine skipped \(file already existed\):\n {2}= includes\/PostTypes\/Product\.php/ + ); + expect(out).toMatch( + /Warnings:\n {2}! file already exists, not overwritten/ + ); + }); + + test('warns about a supplied input the scaffold does not declare', async () => { + const dir = makeProject(); + await runCli([ + 'utility/logger', + '--non-interactive', + '--dry-run', + '--namspace', + 'Typo', + '--cwd', + dir, + ]); + const out = stdout.join(''); + expect(out).toMatch(/\(dry run\), kind: package/); + expect(out).toMatch(/! unknown input "namspace" supplied/); + }); + + test('prints the secrets the developer has to set', async () => { + const dir = makeProject(); + await runCli([ + 'ci/cd-wporg', + '--non-interactive', + '--dry-run', + '--plugin-slug', + 'acme-shop', + '--cwd', + dir, + ]); + const out = stdout.join(''); + expect(out).toMatch( + /Set secrets \(run these yourself; I never set secrets\):/ + ); + expect(out).toMatch( + /gh secret set WPORG_USERNAME\n {4}# WordPress\.org SVN username/ + ); + expect(out).toMatch(/gh secret set WPORG_PASSWORD/); + }); + + test('names the available scaffolds for an unknown id', async () => { + const dir = makeProject(); + expect( + await runCli(['wp/nope', '--non-interactive', '--cwd', dir]) + ).toBe(1); + const err = stderr.join(''); + expect(err).toMatch(/Error: No scaffold registered for slug: wp\/nope/); + expect(err).toMatch(/Available scaffolds:\n {2}\S+/); + }); +}); + +describe('interactive wizard', () => { + test('prompts for the missing inputs, confirms, then scaffolds under a spinner', async () => { + const dir = makeProject(); + promptMissingInputs.mockImplementation( + async ({ supplied, missing }) => { + expect(missing).toEqual( + expect.arrayContaining(['slug', 'singular', 'plural']) + ); + return { + ...supplied, + slug: 'event', + singular: 'Event', + plural: 'Events', + }; + } + ); + ui.confirm.mockResolvedValue(true); + + expect(await runCli(['wp/cpt', '--cwd', dir])).toBe(0); + + expect(ui.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching( + /Scaffold wp\/cpt will create \d+ file/ + ), + }) + ); + const spin = ui.spinner.mock.results[0].value; + expect(spin.succeed).toHaveBeenCalledWith('Scaffolded wp/cpt'); + expect( + fs.existsSync(path.join(dir, 'includes', 'PostTypes', 'Event.php')) + ).toBe(true); + expect(stdout.join('')).toMatch(/\+ includes\/PostTypes\/Event\.php/); + }); + + test('declining the confirm cancels without writing', async () => { + const dir = makeProject(); + ui.confirm.mockResolvedValue(false); + + await expect( + runCli([ + 'wp/cpt', + '--slug', + 'event', + '--singular', + 'Event', + '--plural', + 'Events', + '--cwd', + dir, + ]) + ).rejects.toMatchObject({ name: 'CancelledError' }); + + expect(fs.existsSync(path.join(dir, 'includes'))).toBe(false); + expect(ui.spinner).not.toHaveBeenCalled(); + }); + + test('--dry-run skips the confirm and writes nothing', async () => { + const dir = makeProject(); + + expect( + await runCli([ + 'wp/cpt', + '--dry-run', + '--slug=event', + '--singular=Event', + '--plural=Events', + `--cwd=${dir}`, + ]) + ).toBe(0); + + expect(ui.confirm).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(dir, 'includes'))).toBe(false); + expect(stdout.join('')).toMatch(/Scaffold: wp\/cpt \(dry run\)/); + }); + + test('a failed write marks the spinner failed and exits 1', async () => { + const dir = makeProject(); + // A file where the PostTypes directory has to go. + fs.mkdirSync(path.join(dir, 'includes'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'includes', 'PostTypes'), 'x', 'utf8'); + ui.confirm.mockResolvedValue(true); + + const code = await runCli([ + 'wp/cpt', + '--slug', + 'event', + '--singular', + 'Event', + '--plural', + 'Events', + '--cwd', + dir, + ]); + + expect(code).toBe(1); + const spin = ui.spinner.mock.results[0].value; + expect(spin.fail).toHaveBeenCalledWith('Failed to scaffold wp/cpt'); + expect(stderr.join('')).toMatch(/^Error: /); + }); + + test('an unknown id fails in the discover step', async () => { + const dir = makeProject(); + expect(await runCli(['wp/nope', '--cwd', dir])).toBe(1); + expect(stderr.join('')).toMatch( + /No scaffold registered for slug: wp\/nope/ + ); + expect(promptMissingInputs).not.toHaveBeenCalled(); + }); +}); From 62be6518719aa91938ff0766b1255f028bfff1f3 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:53:50 +0530 Subject: [PATCH 10/15] test(init): cover the FeatureApi helpers skeleton feature hooks rely on The theme's Tailwind toggle flips a PHP define through readDefine and setDefine, and its HMR toggle edits .env.local through readEnv and setEnv; none of those, nor writeFlag or hasDep, had a test. The new suite pins reading and writing, that every mutation's journal entry restores exactly what it changed (including directories a write created), that remove refuses directories, and that paths cannot leave the project. Fixes what it found: the key and define name were interpolated into a regular expression, so a name containing a metacharacter such as '.' matched other lines. Both are now escaped. --- node-packages/wp-tooling/CHANGELOG.md | 1 + node-packages/wp-tooling/src/init/features.js | 24 +- .../wp-tooling/tests/init/feature-api.test.js | 244 ++++++++++++++++++ 3 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 node-packages/wp-tooling/tests/init/feature-api.test.js diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 9055c2b..55d63da 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -44,6 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly. - Generated block JavaScript follows the current shared ESLint formatting rules. - PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics. +- Feature hooks: `readEnv`/`setEnv` and `readDefine`/`setDefine` treat the key or define name as a literal. They were interpolated into a regular expression, so a name containing `.` or another metacharacter could read or flip a different line. - `wp-tooling features`: when `npm install` fails after enabling a feature, the retry commands it prints no longer put runtime dependencies under `--save-dev`, and a failed interactive change reads "Failed to enable " rather than "Failed to enabling ". - `lint/eslint`, `lint/stylelint` and `setup/tailwind` asked for `@rtcamp/eslint-config`, `@rtcamp/stylelint-config` and `@rtcamp/tailwind-config` at `^0.1.0` from the npm registry. None of them is published there (all three 404, and the packages are at 1.0.0), so `npm install` failed, and `features --enable tailwind --record-deps` wrote a spec into package.json that broke the project's next `npm install`. They now use the git specs the skeletons already use (`github:rtCamp/wp-tooling#npm/`). - `lint/phpcs/full` now extends `rtCampWP-Basic` from `rtcamp/wp-phpcs` (and reports it plus `dealerdirect/phpcodesniffer-composer-installer` as dev dependencies) instead of `vendor/rtcamp/wp-framework/phpcs.xml.dist`. The framework export-ignores that file, so every dist install, the Composer default, ended up with a ruleset pointing at a file that was not there. The generated ruleset also sets `testVersion` to `8.2-`, which PHPCompatibilityWP needs. diff --git a/node-packages/wp-tooling/src/init/features.js b/node-packages/wp-tooling/src/init/features.js index 00d84da..33aa5ac 100644 --- a/node-packages/wp-tooling/src/init/features.js +++ b/node-packages/wp-tooling/src/init/features.js @@ -193,6 +193,16 @@ const firstMissingAncestor = (dir) => { return cur; }; +/** + * Escape a literal for use inside a RegExp, so a dotenv key or define name + * such as `APP.DEBUG` matches only itself. + * + * @param {string} value - Literal text. + * @return {string} Pattern source matching exactly `value`. + */ +const escapeRegExp = (value) => + String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + /** * Build the FeatureApi handed to every hook / probe. All mutating calls record * an undo entry in `api._journal` so a feature can be rolled back on failure. @@ -361,7 +371,10 @@ const makeFeatureApi = (root, identity, ui) => { } const raw = fs.readFileSync(abs, 'utf8'); const match = raw.match( - new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*(.*)$`, 'm') + new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*(.*)$`, + 'm' + ) ); return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; }, @@ -371,7 +384,10 @@ const makeFeatureApi = (root, identity, ui) => { setEnv(rel, key, value) { const raw = api.read(rel) || ''; const line = `${key}=${value}`; - const re = new RegExp(`^[ \\t]*${key}[ \\t]*=.*$`, 'm'); + const re = new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=.*$`, + 'm' + ); let next; if (re.test(raw)) { next = raw.replace(re, line); @@ -397,7 +413,7 @@ const makeFeatureApi = (root, identity, ui) => { const raw = fs.readFileSync(abs, 'utf8'); const match = raw.match( new RegExp( - `^[ \\t]*define\\(\\s*['"]${name}['"]\\s*,\\s*(true|false)\\s*\\)`, + `^[ \\t]*define\\(\\s*['"]${escapeRegExp(name)}['"]\\s*,\\s*(true|false)\\s*\\)`, 'im' ) ); @@ -414,7 +430,7 @@ const makeFeatureApi = (root, identity, ui) => { throw new Error(`setDefine: ${rel} not found`); } const re = new RegExp( - `(^[ \\t]*define\\(\\s*['"]${name}['"]\\s*,\\s*)(?:true|false)(\\s*\\))`, + `(^[ \\t]*define\\(\\s*['"]${escapeRegExp(name)}['"]\\s*,\\s*)(?:true|false)(\\s*\\))`, 'im' ); if (!re.test(raw)) { diff --git a/node-packages/wp-tooling/tests/init/feature-api.test.js b/node-packages/wp-tooling/tests/init/feature-api.test.js new file mode 100644 index 0000000..cdb8eb6 --- /dev/null +++ b/node-packages/wp-tooling/tests/init/feature-api.test.js @@ -0,0 +1,244 @@ +/** + * The FeatureApi helpers skeleton feature hooks call to flip a feature: the + * theme's Tailwind toggle rides on readDefine / setDefine and its HMR toggle on + * readEnv / setEnv. Each mutation must journal an undo that restores exactly + * what it changed, and every path must stay inside the project root. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { makeFeatureApi } = require('../../src/init/features'); +const { makeRoot, touch } = require('./_helpers'); + +const IDENTITY = { kind: 'theme', slug: 'demo', features: {} }; +const UI = { + info() {}, + warn() {}, + success() {}, + error() {}, +}; + +const raw = (root, rel) => fs.readFileSync(path.join(root, rel), 'utf8'); + +/** + * Replay the api journal in reverse, as a failed transaction does. + * + * @param {Object} api - FeatureApi whose journal to unwind. + */ +const rollback = async (api) => { + for (let i = api._journal.length - 1; i >= 0; i--) { + await api._journal[i].undo(); + } + api._journal.length = 0; +}; + +let root; +let api; + +beforeEach(() => { + root = makeRoot('feature-api-'); + api = makeFeatureApi(root, IDENTITY, UI); +}); + +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('readEnv / setEnv (dotenv files, the HMR toggle)', () => { + it('reads a value, trimming whitespace and quotes', () => { + touch(root, '.env.local', '# comment\nENABLE_HMR = "false"\nOTHER=1\n'); + expect(api.readEnv('.env.local', 'ENABLE_HMR')).toBe('false'); + expect(api.readEnv('.env.local', 'OTHER')).toBe('1'); + }); + + it('returns null for a missing file or key', () => { + expect(api.readEnv('.env.local', 'ENABLE_HMR')).toBeNull(); + touch(root, '.env.local', 'OTHER=1\n'); + expect(api.readEnv('.env.local', 'ENABLE_HMR')).toBeNull(); + }); + + it('treats a key as a literal, not a pattern', () => { + touch(root, '.env.local', 'APPxDEBUG=1\n'); + expect(api.readEnv('.env.local', 'APP.DEBUG')).toBeNull(); + }); + + it('replaces an existing line in place', () => { + touch(root, '.env.local', 'A=1\nENABLE_HMR=true\nB=2\n'); + api.setEnv('.env.local', 'ENABLE_HMR', 'false'); + expect(raw(root, '.env.local')).toBe('A=1\nENABLE_HMR=false\nB=2\n'); + }); + + it('appends to a file with or without a trailing newline', () => { + touch(root, 'a.env', 'A=1\n'); + touch(root, 'b.env', 'A=1'); + api.setEnv('a.env', 'ENABLE_HMR', 'true'); + api.setEnv('b.env', 'ENABLE_HMR', 'true'); + expect(raw(root, 'a.env')).toBe('A=1\nENABLE_HMR=true\n'); + expect(raw(root, 'b.env')).toBe('A=1\nENABLE_HMR=true\n'); + }); + + it('creates the file when it is missing, and rollback removes it again', async () => { + api.setEnv('.env.local', 'ENABLE_HMR', 'true'); + expect(raw(root, '.env.local')).toBe('ENABLE_HMR=true\n'); + + await rollback(api); + + expect(fs.existsSync(path.join(root, '.env.local'))).toBe(false); + }); +}); + +describe('readDefine / setDefine (PHP defines, the Tailwind toggle)', () => { + const ENTRY = 'functions.php'; + const NAME = 'DEMO_ENABLE_TAILWIND'; + + it('reads true, false and a mixed-case value', () => { + touch(root, ENTRY, ` { + touch(root, ENTRY, ` { + touch(root, ENTRY, ` { + const before = ` { + expect(() => api.setDefine('missing.php', NAME, true)).toThrow( + /setDefine: missing\.php not found/ + ); + touch(root, ENTRY, ' api.setDefine(ENTRY, NAME, true)).toThrow( + new RegExp( + `define\\( '${NAME}', \\.\\.\\. \\) not found in ${ENTRY}` + ) + ); + }); +}); + +describe('writeFlag / hasDep', () => { + it('writes .wp-features.json, merging into existing flags, and rolls back', async () => { + api.writeFlag('tailwind', true); + expect(JSON.parse(raw(root, '.wp-features.json'))).toEqual({ + tailwind: true, + }); + api.writeFlag('hmr', false); + expect(JSON.parse(raw(root, '.wp-features.json'))).toEqual({ + tailwind: true, + hmr: false, + }); + + await rollback(api); + + expect(fs.existsSync(path.join(root, '.wp-features.json'))).toBe(false); + }); + + it('restores the previous flags file on rollback', async () => { + touch(root, '.wp-features.json', '{\n\t"tailwind": false\n}\n'); + api.writeFlag('tailwind', true); + await rollback(api); + expect(raw(root, '.wp-features.json')).toBe( + '{\n\t"tailwind": false\n}\n' + ); + }); + + it('finds a dependency in either section, and none without package.json', () => { + expect(api.hasDep('tailwindcss')).toBe(false); + touch( + root, + 'package.json', + JSON.stringify({ + dependencies: { react: '^18' }, + devDependencies: { tailwindcss: '^4' }, + }) + ); + expect(api.hasDep('tailwindcss')).toBe(true); + expect(api.hasDep('react')).toBe(true); + expect(api.hasDep('left-pad')).toBe(false); + }); +}); + +describe('write / remove / editJson', () => { + it('rollback of a nested write removes the directories it created', async () => { + api.write('src/css/frontend/tailwind.css', '@import "tailwindcss";\n'); + expect(fs.existsSync(path.join(root, 'src/css/frontend'))).toBe(true); + + await rollback(api); + + expect(fs.existsSync(path.join(root, 'src'))).toBe(false); + }); + + it('remove restores the file on rollback and refuses directories', async () => { + touch(root, 'postcss.config.js', 'module.exports = {};\n'); + api.remove('postcss.config.js'); + expect(fs.existsSync(path.join(root, 'postcss.config.js'))).toBe(false); + api.remove('not-there.js'); // a no-op, not an error + + await rollback(api); + + expect(raw(root, 'postcss.config.js')).toBe('module.exports = {};\n'); + fs.mkdirSync(path.join(root, 'build')); + expect(() => api.remove('build')).toThrow( + /refusing to delete directory/ + ); + }); + + it('editJson names the file when it is not valid JSON', () => { + touch(root, 'package.json', '{ nope'); + expect(() => api.editJson('package.json', () => {})).toThrow( + /editJson: package\.json is not valid JSON/ + ); + }); + + it('editJson create takes its indentation from indentFrom', () => { + touch(root, 'package.json', '{\n "name": "demo"\n}\n'); + api.editJson( + 'config/tooling.json', + (obj) => { + obj.a = 1; + }, + { create: true, indentFrom: 'package.json' } + ); + expect(raw(root, 'config/tooling.json')).toBe('{\n "a": 1\n}\n'); + }); + + it('editJson without create refuses a missing file', () => { + expect(() => api.editJson('nope.json', () => {})).toThrow( + /editJson: nope\.json not found/ + ); + }); +}); + +describe('paths stay inside the project', () => { + it.each([ + ['read', (a) => a.read('../outside.txt')], + ['write', (a) => a.write('../outside.txt', 'x')], + ['setEnv', (a) => a.setEnv('../.env', 'A', '1')], + ['readDefine', (a) => a.readDefine('../functions.php', 'X')], + ])('%s rejects a path that escapes the root', (_name, call) => { + expect(() => call(api)).toThrow(); + expect( + fs.existsSync(path.join(path.dirname(root), 'outside.txt')) + ).toBe(false); + }); +}); From b076fe2eef48550a7c2642d3972343cf2f3d5e61 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 06:59:04 +0530 Subject: [PATCH 11/15] test: cover manage-mode flags, list CLI errors and the Tailwind webpack hooks - init manage mode driven by flags, the way CI and AI callers run it (--features, --enable, --disable, --yes alone refused, undeclared keys rejected), plus the read-only status view (drift, unknown state, retired keys); - wp-tooling list: space-separated flag forms, argument errors, the empty table, and a registry that fails to load, in plain and --json output; - tailwind-config: GenerateTailwindThemePlugin.apply(), which wires the run, watchRun (skip rebuilds that did not touch theme.json) and afterEmit hooks. Its functions go from 60% to 100% covered. --- .../tests/GenerateTailwindThemePlugin.test.js | 89 ++++++++ .../tests/init/manage-flags.test.js | 214 ++++++++++++++++++ .../tests/scaffolds/list-cli.test.js | 108 +++++++++ 3 files changed, 411 insertions(+) create mode 100644 node-packages/wp-tooling/tests/init/manage-flags.test.js create mode 100644 node-packages/wp-tooling/tests/scaffolds/list-cli.test.js diff --git a/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js b/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js index fc2466d..e26f9d0 100644 --- a/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js +++ b/node-packages/tailwind-config/tests/GenerateTailwindThemePlugin.test.js @@ -360,3 +360,92 @@ describe('GenerateTailwindThemePlugin.generate()', () => { ); }); }); + +describe('GenerateTailwindThemePlugin.apply()', () => { + /** + * A webpack compiler double: records each hook's tapped callback. + * + * @return {Object} `{ compiler, taps }`. + */ + const fakeCompiler = () => { + const taps = {}; + const hook = (name) => ({ + tap: (pluginName, fn) => { + taps[name] = { pluginName, fn }; + }, + }); + return { + taps, + compiler: { + hooks: { + run: hook('run'), + watchRun: hook('watchRun'), + afterEmit: hook('afterEmit'), + }, + }, + }; + }; + + let plugin; + let generate; + const themeJsonPath = path.join(os.tmpdir(), 'apply-test', 'theme.json'); + + beforeEach(() => { + plugin = new GenerateTailwindThemePlugin({ + themeJson: themeJsonPath, + tailwindCss: path.join(os.tmpdir(), 'apply-test', 'tailwind.css'), + }); + generate = jest.spyOn(plugin, 'generate').mockImplementation(() => {}); + }); + + afterEach(() => jest.restoreAllMocks()); + + test('taps run, watchRun and afterEmit under the plugin name', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + expect(Object.keys(taps).sort()).toEqual([ + 'afterEmit', + 'run', + 'watchRun', + ]); + for (const tap of Object.values(taps)) { + expect(tap.pluginName).toBe('GenerateTailwindThemePlugin'); + } + }); + + test('a single build generates once', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + taps.run.fn(); + expect(generate).toHaveBeenCalledTimes(1); + }); + + test('watch mode generates on the first run and when theme.json changed', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + + taps.watchRun.fn({ modifiedFiles: undefined }); + taps.watchRun.fn({ modifiedFiles: new Set([themeJsonPath]) }); + + expect(generate).toHaveBeenCalledTimes(2); + }); + + test('watch mode skips rebuilds that did not touch theme.json', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + + taps.watchRun.fn({ modifiedFiles: new Set(['/src/other.css']) }); + + expect(generate).not.toHaveBeenCalled(); + }); + + test('every emit re-registers theme.json as a file dependency', () => { + const { compiler, taps } = fakeCompiler(); + plugin.apply(compiler); + const compilation = { fileDependencies: new Set() }; + + taps.afterEmit.fn(compilation); + + expect(compilation.fileDependencies.has(themeJsonPath)).toBe(true); + }); +}); diff --git a/node-packages/wp-tooling/tests/init/manage-flags.test.js b/node-packages/wp-tooling/tests/init/manage-flags.test.js new file mode 100644 index 0000000..ccbca2c --- /dev/null +++ b/node-packages/wp-tooling/tests/init/manage-flags.test.js @@ -0,0 +1,214 @@ +/** + * Manage mode driven by flags, the way CI and AI callers run it + * (`npm run init -- --enable=tailwind --yes`), plus the read-only status view. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { + manageFlow, + parseManageFlags, + showStatus, +} = require('../../src/init/manage'); +const { identityFromName } = require('../../src/init/identity'); +const { makeRoot, touch } = require('./_helpers'); + +const makeUi = () => ({ + radio: jest.fn(), + text: jest.fn(), + confirm: jest.fn(async () => true), + table: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + success: jest.fn(), + error: jest.fn(), + heading: jest.fn(), + spinner: () => ({ start() {}, succeed() {}, fail() {} }), +}); + +/** + * A feature whose state is a marker file, so tests can see it flip. + * + * @param {string} key - Feature key. + * @return {Object} Feature declaration. + */ +const markerFeature = (key) => ({ + key, + label: key.toUpperCase(), + description: `The ${key} feature.`, + detect: (api) => api.exists(`${key}.flag`), + onEnable: (api) => api.write(`${key}.flag`, 'on'), + onDisable: (api) => api.remove(`${key}.flag`), +}); + +const CONFIG = { + kind: 'theme', + features: [markerFeature('alpha'), markerFeature('beta')], +}; + +let root; +let identity; +let ui; + +beforeEach(() => { + root = makeRoot('manage-flags-'); + identity = { + ...identityFromName('Acme Blog', CONFIG), + version: '1.0.0', + features: { alpha: false, beta: false }, + }; + touch(root, '.wp-scaffold.json', JSON.stringify(identity)); + ui = makeUi(); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = 0; +}); + +const persisted = () => + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'), 'utf8')) + .features; + +describe('parseManageFlags', () => { + test('reads --yes/-y, list flags and the --manage marker', () => { + expect( + parseManageFlags([ + '-y', + '--manage', + '--features=alpha, beta', + '--enable=alpha', + '--disable=beta', + ]) + ).toEqual({ + flags: { + yes: true, + features: ['alpha', 'beta'], + enable: ['alpha'], + disable: ['beta'], + }, + unknown: [], + }); + }); + + test('collects anything else as unknown', () => { + expect(parseManageFlags(['--yes', '--wat']).unknown).toEqual(['--wat']); + }); +}); + +describe('manageFlow with flags', () => { + test('--enable turns a feature on and records it', async () => { + await manageFlow( + CONFIG, + root, + ['--enable=alpha', '--yes'], + identity, + ui, + jest.fn() + ); + + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(true); + expect(fs.existsSync(path.join(root, 'beta.flag'))).toBe(false); + expect(persisted()).toMatchObject({ alpha: true, beta: false }); + expect(ui.radio).not.toHaveBeenCalled(); + }); + + test('--features sets the exact set, turning off what it leaves out', async () => { + touch(root, 'alpha.flag', 'on'); + await manageFlow( + CONFIG, + root, + ['--features=beta', '--yes'], + { ...identity, features: { alpha: true, beta: false } }, + ui, + jest.fn() + ); + + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(false); + expect(fs.existsSync(path.join(root, 'beta.flag'))).toBe(true); + expect(persisted()).toMatchObject({ alpha: false, beta: true }); + }); + + test('--disable turns an enabled feature off', async () => { + touch(root, 'beta.flag', 'on'); + await manageFlow( + CONFIG, + root, + ['--disable=beta', '--yes'], + { ...identity, features: { alpha: false, beta: true } }, + ui, + jest.fn() + ); + + expect(fs.existsSync(path.join(root, 'beta.flag'))).toBe(false); + expect(persisted()).toMatchObject({ beta: false }); + }); + + test('--yes alone is refused with exit code 1, changing nothing', async () => { + await manageFlow(CONFIG, root, ['--yes'], identity, ui, jest.fn()); + + expect(ui.error).toHaveBeenCalledWith( + '--yes in manage mode requires --features / --enable / --disable.' + ); + expect(process.exitCode).toBe(1); + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(false); + }); + + test('an undeclared feature key is rejected before anything runs', async () => { + await expect( + manageFlow( + CONFIG, + root, + ['--enable=gamma', '--yes'], + identity, + ui, + jest.fn() + ) + ).rejects.toThrow(/gamma/); + expect(fs.existsSync(path.join(root, 'alpha.flag'))).toBe(false); + }); +}); + +describe('showStatus', () => { + test('says so when no optional features are declared', () => { + showStatus([], [], ui); + expect(ui.info).toHaveBeenCalledWith( + 'No optional features are declared for this project.' + ); + expect(ui.table).not.toHaveBeenCalled(); + }); + + test('labels each state, flags drift, prints descriptions and retired keys', () => { + showStatus( + [ + { + key: 'a', + label: 'A', + on: true, + drift: false, + description: 'First.', + }, + { key: 'b', label: 'B', on: false, drift: true }, + { key: 'c', label: 'C', on: null, drift: false }, + ], + ['legacy'], + ui + ); + + expect(ui.table).toHaveBeenCalledWith( + [ + ['A', 'enabled'], + ['B', 'disabled (drift)'], + ['C', 'unknown'], + ], + { title: 'Feature status' } + ); + expect(ui.info).toHaveBeenCalledWith('A: First.'); + expect(ui.warn).toHaveBeenCalledWith( + 'legacy: recorded in .wp-scaffold.json but no longer declared.' + ); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/list-cli.test.js b/node-packages/wp-tooling/tests/scaffolds/list-cli.test.js new file mode 100644 index 0000000..c01e645 --- /dev/null +++ b/node-packages/wp-tooling/tests/scaffolds/list-cli.test.js @@ -0,0 +1,108 @@ +/** + * `wp-tooling list` paths the main suite does not reach: the space-separated + * flag forms, argument errors, the empty human table, and a registry that + * fails to load (plain and --json error output). + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { runCli, parseArgs } = require('../../src/scaffolds/list'); + +const tempDirs = []; +let stdout; +let stderr; + +function makeProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-tooling-list-cli-')); + tempDirs.push(dir); + return dir; +} + +// A project whose bin/scaffolds holds a manifest that fails validation, so the +// registry scan throws EBADSCAFFOLD. +function projectWithBrokenScaffold() { + const dir = makeProject(); + const scaffold = path.join(dir, 'bin', 'scaffolds', 'broken'); + fs.mkdirSync(scaffold, { recursive: true }); + fs.writeFileSync( + path.join(scaffold, 'scaffold.json'), + JSON.stringify({ slug: 'broken' }), + 'utf8' + ); + return dir; +} + +beforeEach(() => { + stdout = []; + stderr = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parses the space-separated forms of every value flag', () => { + expect( + parseArgs([ + '--cwd', + '/tmp/x', + '--category', + 'wp', + '--origin', + 'default', + '--cache-dir', + '/tmp/c', + '--refresh', + ]) + ).toMatchObject({ + cwd: '/tmp/x', + category: 'wp', + origin: 'default', + cacheDir: '/tmp/c', + refresh: true, + }); + expect(parseArgs(['--cache-dir=/tmp/d']).cacheDir).toBe('/tmp/d'); +}); + +test('an unexpected argument exits 1 and prints the usage', async () => { + expect(await runCli(['--wat'])).toBe(1); + expect(stderr.join('')).toMatch(/Unexpected argument: --wat/); + expect(stdout.join('')).toMatch(/Usage/); +}); + +test('a filter that matches nothing says so in the human table', async () => { + const dir = makeProject(); + expect(await runCli(['--category', 'no-such-category', '--cwd', dir])).toBe( + 0 + ); + expect(stdout.join('')).toMatch(/No scaffolds match the given filters\./); +}); + +test('a registry that fails to load exits 1 with the error', async () => { + const dir = projectWithBrokenScaffold(); + expect(await runCli(['--cwd', dir])).toBe(1); + expect(stderr.join('')).toMatch(/^Error: /); +}); + +test('with --json the load error is one JSON object on stderr', async () => { + const dir = projectWithBrokenScaffold(); + expect(await runCli(['--json', '--cwd', dir])).toBe(1); + expect(JSON.parse(stderr.join('').trim())).toMatchObject({ + code: 'EBADSCAFFOLD', + }); +}); From bb39efbcefc1cd79542e2f2bc0eb81cf1c2b66e0 Mon Sep 17 00:00:00 2001 From: AnuragVasanwala <75766877+AnuragVasanwala@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:44:45 +0530 Subject: [PATCH 12/15] docs(changelog): restore the wp/cli description escaping note The merge from main kept the older wording and dropped the entry for the wp/cli description fix that #70 shipped. --- node-packages/wp-tooling/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 315474c..9ebc433 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -40,7 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- Human-readable scaffold labels now escape apostrophes and backslashes in PHP and JavaScript string literals, and quotes in block JSON metadata. Titles such as `Editor's "guide"` generate valid code while preserving the original display text. +- Human-readable scaffold labels and the `wp/cli` command description now escape apostrophes and backslashes in PHP and JavaScript string literals, and quotes in block JSON metadata. Titles such as `Editor's "guide"` generate valid code while preserving the original display text. - Block and script module asset paths now derive the project root from `base_path`, so nested or root-level PSR-4 directories resolve build output correctly. - Generated block JavaScript follows the current shared ESLint formatting rules. - PHPCS scaffolds exclude intentionally broken AI evaluation fixtures. The standalone server profiler uses short arrays and documents the specific PHPCS exceptions required by WP-CLI evaluation, privileged CLI query data and STDERR diagnostics. From 4de6c16064052d0ff838a4d99aad5dc6be274364 Mon Sep 17 00:00:00 2001 From: AnuragVasanwala <75766877+AnuragVasanwala@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:44:45 +0530 Subject: [PATCH 13/15] docs(examples): install @rtcamp/eslint-config at ^1.0.0 The package publishes to npm as 1.0.0, and ^0.1.0 only matches versions below 0.2.0. --- node-packages/wp-tooling/docs/examples.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-packages/wp-tooling/docs/examples.md b/node-packages/wp-tooling/docs/examples.md index f75969d..e02da3d 100644 --- a/node-packages/wp-tooling/docs/examples.md +++ b/node-packages/wp-tooling/docs/examples.md @@ -97,7 +97,7 @@ Developer actions: composer require rtcamp/wp-primitives:^2.0 composer require --dev rtcamp/wp-phpcs:^1.0 phpunit/phpunit:^12.0 yoast/phpunit-polyfills:^4.0 brain/monkey:^2.6 - npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^0.1.0 + npm install --save-dev eslint@^10.0.0 @wordpress/eslint-plugin@^25.1.0 @rtcamp/eslint-config@^1.0.0 Scripts to add to composer.json: "lint:php": "phpcs --standard=phpcs.xml.dist", "test": "phpunit", ... From 3f2e1ee787f04514877f5e4e6016fc4a486980d9 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 22:48:05 +0530 Subject: [PATCH 14/15] fix(ci): export DetectChangesError from @rtcamp/wp-tooling/ci The class was exported from src/ci/detect-changes.js but not from the ci barrel, so consumers of the documented @rtcamp/wp-tooling/ci entry point could not catch it by type. Adds the export and a test next to the existing GateError one. --- node-packages/wp-tooling/src/ci/index.js | 2 ++ .../wp-tooling/tests/ci/detect-changes-git.test.js | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/node-packages/wp-tooling/src/ci/index.js b/node-packages/wp-tooling/src/ci/index.js index 0905c9b..914c3ef 100644 --- a/node-packages/wp-tooling/src/ci/index.js +++ b/node-packages/wp-tooling/src/ci/index.js @@ -6,6 +6,7 @@ const { detectChanges, + DetectChangesError, DEFAULT_PATTERNS, DEFAULT_IGNORE, } = require('./detect-changes'); @@ -21,6 +22,7 @@ const { module.exports = { detectChanges, + DetectChangesError, DEFAULT_PATTERNS, DEFAULT_IGNORE, computeGate, diff --git a/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js b/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js index 263af7a..9e59c3e 100644 --- a/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js +++ b/node-packages/wp-tooling/tests/ci/detect-changes-git.test.js @@ -336,3 +336,9 @@ describe('GitHub output heredocs', () => { expect(formatGithubLine('php-files', [])).toBe('php-files='); }); }); + +test('@rtcamp/wp-tooling/ci exposes DetectChangesError', () => { + const ci = require('../../src/ci'); + expect(ci.DetectChangesError).toBe(DetectChangesError); + expect(new ci.DetectChangesError('x')).toBeInstanceOf(Error); +}); From 28dd2bfaa365336b79428c6a9d1c3bcd4243c040 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 1 Oct 2026 22:48:43 +0530 Subject: [PATCH 15/15] docs(changelog): separate the Unreleased sections --- composer-packages/phpcs/CHANGELOG.md | 1 + composer-packages/phpstan/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/composer-packages/phpcs/CHANGELOG.md b/composer-packages/phpcs/CHANGELOG.md index 7462de2..178b5c6 100644 --- a/composer-packages/phpcs/CHANGELOG.md +++ b/composer-packages/phpcs/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist page links to the monorepo for issues and source (the mirror repository is a read-only split). + ### Changed - README: a header with the license, PHP and tool-version badges, and a note that the diff --git a/composer-packages/phpstan/CHANGELOG.md b/composer-packages/phpstan/CHANGELOG.md index 9ddd2d3..9365eff 100644 --- a/composer-packages/phpstan/CHANGELOG.md +++ b/composer-packages/phpstan/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist page links to the monorepo for issues and source (the mirror repository is a read-only split). + ### Changed - README: a header with the license, PHP and tool-version badges, and a note that the