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

Filter by extension

Filter by extension

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

### Added

- `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 `<base_path>/Helpers/Util.php` under anchor `// scaffold:utility/<slug>`. `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.
- Remote scaffolds — a scaffold's `scaffold.json` + templates can live in another repo. `scaffolds/sources.json` lists the source repos (pinned `{ repository, ref, path }`); each repo publishes a `scaffolds/index.json` enumerating the scaffolds it offers, which the registry fetches to discover them (one PR in the owning repo adds/changes a scaffold; wp-tooling only changes to onboard a new repo). Manifests + templates are fetched on `add`, cached under `${XDG_CACHE_HOME:-$HOME/.cache}/wp-tooling/remote/` and validated with ETag conditional requests (`304 Not Modified` serves the cache; movable tags refresh when they move). New error code `EFETCHFAIL` (network/HTTP) distinct from `EBADSCAFFOLD` (bad index/manifest). `list` is online-preferred with a cache fallback and reports unreachable sources as warnings; `validate --remote` fetches + schema-validates each index + manifest; `wp-tooling cache clear` empties the cache. Dormant by default — no `sources.json` ships.
- Engine-side input discovery (`discover_from`) — an input declaration can say where to source its value from the project, so the engine fills it instead of the caller guessing. Resolves from `composer.json` / `package.json` (dotted paths; `autoload.psr-4` yields the root namespace) and `.wp-tooling.json`, with precedence `supplied → discovered → default`. Fail-safe: a missing or malformed project file falls through to the input's `default`, so a project without those files behaves exactly as before the resolver existed. Adds an optional `transform` step for derived inputs (`json-escape` doubles backslashes for the PSR-4 composer key). The inputs the engine actually rendered with are surfaced on `execute()` as `engine.inputs`. Bundled `setup/psr4` + `wp/*` scaffolds annotated with `discover_from`.
- Feature toggle layer — a scaffold may declare an optional `feature` block (`config_key`, `owned_files`, `confirm_remove`, `gitignore`) marking it as a toggleable project feature. New TTY-free `enable` / `disable` / `status` verbs create or remove the owned files idempotently, manage `.gitignore` lines (Mustache-rendered against resolved inputs), prompt before deleting consumer-editable files (`confirm_remove`, overridable with `--force`), and persist on/off state in `.wp-tooling.json`. New `wp-tooling features` command — lists feature status by default, with `--enable` / `--disable` to toggle (plus `--json`, `--force`, `--no-install`, `--dry-run`); `setup/tailwind` ships as the first such feature. Additive — the `feature` block never affects the `add` / `execute` path.
Expand Down
2 changes: 1 addition & 1 deletion node-packages/wp-tooling/docs/ai-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The engine commits to the following on a successful run. Skills can rely on thes
- The engine is idempotent under `scaffold.dryRun: true`: identical inputs produce identical output across runs.
- The engine never reads or writes any path outside `--cwd`. This is enforced: a rendered `dest`, test path, or feature-owned path that resolves outside the target directory (e.g. via `..` in a path input or a third-party remote manifest) fails with `EWRITEFAIL` (`errno: "EOUTSIDE"`) before anything touches the filesystem.
- The engine never invokes `gh`, `git`, `composer`, `npm`, or any other external CLI on behalf of the caller.
- `scaffold.kind` is `"package"` for `source: "package"` scaffolds (no files written, only deps and wiring) and `"template"` otherwise. Remote (sources) scaffolds also report `kind: "template"` — they render Mustache the same way as local ones; where the scaffold lives is an implementation detail the orchestrator does not need to branch on. Callers branch on `kind` rather than checking `engine.wrote.length === 0`.
- `scaffold.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:<dot.path>`, `package.json:<dot.path>`, `config:<key>` from `.wp-tooling.json`) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` and `plugin-header:*` sources are **not** engine-resolved and remain the skill's responsibility (§6).
Expand Down
19 changes: 19 additions & 0 deletions node-packages/wp-tooling/docs/authoring-scaffolds.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,24 @@ Use sections inside `snippet_template` to vary the snippet by flag (e.g. the sin

---

## Package scaffolds (`source: "package"`)

A package scaffold wires up a class that already ships in `vendor/` rather than generating one. It
declares `files: []`, the `composer_dependencies` entry that provides the class, and the `wiring[]`
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
documenting which vendor class the `wiring[]` snippet constructs. Optional, but set it on every
package scaffold.

The `utility/*` scaffolds are the bundled examples. Note what a package scaffold **cannot** do: it
cannot create the wrapper class its snippet might want, because `files[]` is empty. Keep the snippet
self-sufficient against an existing file, and use `description` to spell out the alternatives the
project may prefer.

---

## Tests, secrets, scripts

- `tests[]`: test stubs written alongside production output. Each entry has `src`, `dest`, `framework` (`phpunit`, `jest`, `playwright`, `pa11y`, `actionlint`, `yaml-parse`), and optional `command`.
Expand Down Expand Up @@ -366,6 +384,7 @@ Look at these existing scaffolds when authoring a new one:
| Module that hosts other Registrable classes | `wp/module` |
| Static config file (no inputs) | `setup/editorconfig` |
| Wiring into an existing JSON file | `setup/psr4` |
| `source: package` — zero files, a Composer dep plus one wiring snippet | `utility/cache`, `utility/timer` |
| Multiple variants of the same concept | `lint/phpcs/{full,core,vip}` |
| Block with `block.json` + framework class | `wp/block-dynamic` |
| Workflow / YAML scaffold with secrets | `ci/cd-wporg` |
Expand Down
34 changes: 34 additions & 0 deletions node-packages/wp-tooling/docs/wp-framework-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,31 @@ interface CLICommand {

---

## Utility classes (targeted by `scaffolds/utility/*`)

All under `rtCamp\WPFramework\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
(`<base_path>/Helpers/Util.php`) rather than adding them to an `AbstractModule` or `Main::CLASSES`:
the `Loader` only calls `register_hooks()` / caches `Shareable`, so a listed `Utils\*` class would be
constructed with no argument — silently wrong for `Cache`, `Logger` and `FeatureSelector`, and fatal
for `Transients`.

| Class | Constructor | Key methods |
|---|---|---|
| `Cache` | `__construct(string $context = '')` — prefixes every group as `context:group` | `get`, `set`, `delete`, `flush_group`, `remember`, `remember_swr` |
| `Transients` | `__construct(string $prefix)` — **required**, `readonly` | `get`, `set`, `delete` |
| `Logger` | `__construct(string $prefix = 'rtcamp')` | `log`, `debug`, `info`, `warning`, `error` — silent unless `WP_DEBUG` |
| `Timer` | none — **takes no arguments** | `start`, `stop`, `lap`, `get`, `get_all` |
| `FeatureSelector` | `__construct(string $context = '')` — derives the option key and constant name | `register`, `is_enabled`, `enable`, `disable`, `get_registered`, `shared_option_key` |

Spelling is `FeatureSelector`, not `Feature_Selector`. `Timer` and `FeatureSelector` hold state on the
instance (laps; the flag registry), so they must be shared rather than constructed per call.
`FeatureSelectorSettingsPage` is an abstract admin UI over `FeatureSelector`, requiring a
`get_selector(): FeatureSelector` implementation.

---

## Optional helpers (not currently scaffolded)

- `AssetLoaderTrait`: register scripts, styles, block manifests with versioning from a build's `.asset.php` file.
Expand All @@ -204,3 +229,12 @@ Marker pattern: `// scaffold:<scaffold-id>:classes`. Examples:
- `// scaffold:wp/registrable:classes` inside `Modules/Services.php`

Each `Modules/<Name>.php` file is itself produced by the `wp/module` scaffold, which accepts a `kind` input that selects which anchor to emit.

The `utility/*` scaffolds are the exception, because they register no class with the `Loader`. Their
marker takes no `:classes` suffix and sits in the consumer's static helper, not a module:

- `// scaffold:utility/cache`, `utility/transients`, `utility/logger`, `utility/timer`,
`utility/feature-selector` — all inside `Helpers/Util.php`

No scaffold emits these markers, so expect them to be absent; fall back to sampling the existing
accessors in that file.
Original file line number Diff line number Diff line change
Expand Up @@ -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:<kind>:classes`) are hints, not ground truth. Sampled patterns win.
Anchors (`// scaffold:<kind>:classes`, and `// scaffold:utility/<slug>` in `Helpers/Util.php`) are hints, not ground truth. Sampled patterns win.

Confirm findings with the developer in one short message. Proceed on confirmation.

Expand All @@ -77,6 +77,8 @@ Files group by **kind**, never by feature. `<Root>` = project's autoload root (e
| `wp/cron` | `includes/Cron/` | `<Root>\Cron` | `tests/Cron/` | `<Root>\Tests\Cron` | `<Root>\Modules\Cron` |
| `wp/registrable` | `includes/Services/` | `<Root>\Services` | `tests/Services/` | `<Root>\Tests\Services` | `<Root>\Modules\Services` |

`utility/*` is absent from the table on purpose: those are `source: package`, so they have no source dir, no test dir and no module. Each returns one accessor snippet for `<base_path>/Helpers/Util.php` under anchor `// scaffold:utility/<slug>`. Never add a framework `Utils\*` class to a module or to `Main::CLASSES` — they implement neither `Registrable` nor `Shareable`, so the Loader would construct them with the wrong (or a missing) constructor argument.

**Modules host one kind each. No `Modules/<Feature>/...`.** A multi-kind feature (e.g. Testimonials = CPT + taxonomy + block + REST) spans the per-kind directories and wires into each kind's module.

If the project already has a per-feature module folder, flag as anti-pattern. Offer migration before adding new artifacts. Do not scaffold into it.
Expand Down Expand Up @@ -163,6 +165,7 @@ Frameworks per kind:
| `wp/block-dynamic` | Jest (edit.js) + PHPUnit (render method) |
| `block/interactive` | Jest + Playwright |
| `ci/*` | actionlint + yaml-parse |
| `utility/*` | none - no files written, so no stub to expand. Cover the calling code instead. |

### 8. Escalate when stuck - do not guess

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,18 @@ Map each feature the developer mentioned to one or more scaffold IDs from the ca
| REST endpoint / API | `wp/rest` (if available) |
| Cron job / background job | `wp/cron` (if available) |
| Gutenberg block | `wp/block-dynamic` |
| Cache / transients | `utility/cache` |
| Object caching | `utility/cache` |
| Transients | `utility/transients` |
| Debug logging | `utility/logger` |
| Timing a slow code path | `utility/timer` |
| Feature flags / gradual rollout | `utility/feature-selector` |
| 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 `<base_path>/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).

Run `npx wp-tooling list --json` to see exactly what is available. If a feature the developer wants has no matching scaffold, note it explicitly as a manual task in the final report.

For each feature scaffold, you need the same project-convention information as the `scaffold` skill requires (namespace, base path, class suffix, registration pattern). Collect this once from the project and cache it.
Expand Down
34 changes: 34 additions & 0 deletions node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"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.",
"source": "package",
"module_class": "rtCamp\\WPFramework\\Utils\\Cache",
"files": [],
"inputs": [
{
"key": "base_path",
"description": "Directory holding the PHP source, relative to the project root, without a trailing slash. Default 'includes' matches the rtCamp skeleton; pass 'inc' or 'src' for projects that differ.",
"default": "includes"
},
{
"key": "context_slug",
"description": "Namespace every cache group is scoped to, so this project's groups can never collide with another consumer's sharing the same object cache. Discovered from the composer package name and normalised to snake_case, so 'rtcamp/acme-blog' becomes 'rtcamp_acme_blog'. Pass an explicit value to override.",
"discover_from": "composer.json:name",
"transform": "snake-case",
"default": "my_plugin"
}
],
"wiring": [
{
"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."
}
],
"composer_dependencies": {
"rtcamp/wp-framework": "^1.0"
}
}
Loading
Loading