From 6e32a1afadf133bcb36086ef58e23095eac2ee8d Mon Sep 17 00:00:00 2001 From: Divyaraj Masani Date: Thu, 3 Sep 2026 23:20:56 +0530 Subject: [PATCH] docs: overhaul consumer and maintainer documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audits the docs against the code and closes the gaps a consumer hits first. No `inc/` behavior changes. New pages: - getting-started.md — smallest complete integration: install, a Registrable service, a module, bootstrap, and Shareable/get_shared(). - upgrading.md — the versioning promise stated against inc/Contracts/, plus the 1.0.0 -> 1.0.1 Singleton migration. - troubleshooting.md — symptom -> cause for the framework's exceptions, _doing_it_wrong() notices, and silent no-ops. - maintainers.md — environment, checks, test conventions, change checklist per surface. - issues/maintainer-documentation-gaps.md — a filled-in task issue for the maintainer-side gaps: release procedure, SECURITY.md, the wp-env mount note, a doc-drift guard, and the composer.json script contradiction. Four open decisions left for the maintainers. Install path corrected: the package is not on public Packagist (repo.packagist.org 404s for rtcamp/wp-framework), so the documented `composer require` could not resolve. README and getting-started now show the VCS `repositories` entry and a `^1.0` constraint. Accuracy fixes: - architecture.md — the load-loop snippet claimed `array_unique( $classes )`; Loader::load() uses a `$seen` map. - abstracts.md — the `option_page_capability_{group}` filter is unconditional; a lowered capability is the case where it matters, not the trigger. AbstractRESTController throws \LogicException, not a bare \Exception. - contracts.md, architecture.md — Singleton behavior corrected to match the 1.0.1 fix: the re-entrancy guard and the shared subclass slot. Coverage: a worked WP-CLI example (CLICommand was the only contract without one); AssetLoader path safety and handle helpers; the component and template hook tables; the Cache, FeatureSelector, and Timer APIs; README requirements, a quick-look snippet, and the missing utilities. Contributor workflow: CONTRIBUTING and README route to `composer lint`, `composer analyse`, `npm run test:php` — `composer check`/`test` run PHPUnit on the host and need a separate WordPress test suite. AGENTS.md and .github/instructions/ gain AbstractFeature, which was missing from the class inventory. All relative links verified to resolve. Co-Authored-By: Claude Opus 5 (1M context) --- .github/instructions/php.instructions.md | 2 +- AGENTS.md | 6 +- CHANGELOG.md | 16 +- CONTRIBUTING.md | 30 ++-- README.md | 78 +++++++-- ai/framework-php.instructions.md | 2 +- docs/abstracts.md | 12 +- docs/architecture.md | 19 ++- docs/contracts.md | 78 ++++++++- docs/getting-started.md | 198 +++++++++++++++++++++++ docs/index.md | 16 +- docs/loaders.md | 43 +++++ docs/maintainers.md | 141 ++++++++++++++++ docs/troubleshooting.md | 73 +++++++++ docs/upgrading.md | 74 +++++++++ docs/utilities.md | 80 ++++++++- 16 files changed, 824 insertions(+), 44 deletions(-) create mode 100644 docs/getting-started.md create mode 100644 docs/maintainers.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/upgrading.md diff --git a/.github/instructions/php.instructions.md b/.github/instructions/php.instructions.md index 7dea226..e2b43ba 100644 --- a/.github/instructions/php.instructions.md +++ b/.github/instructions/php.instructions.md @@ -8,7 +8,7 @@ description: "Framework-development rules for rtcamp/wp-framework PHP." ## Layout & contracts - `inc/Contracts/Interfaces/`: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`. -- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`. +- `inc/Contracts/Abstracts/`: `AbstractModule`, `AbstractFeature`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`. - `inc/Contracts/Traits/`: `Loader`, `Singleton`. - `inc/` root: `Container`, `AssetLoader`, `ComponentLoader`, `TemplateLoader`; `inc/Utils/`: utilities (e.g. `Encryptor`). diff --git a/AGENTS.md b/AGENTS.md index 3232365..c09e24b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — wp-framework -Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Codex). `rtcamp/wp-framework`: shared base contracts (interfaces, abstracts, traits) and small utilities consumed via Composer by every rtCamp plugin/theme skeleton. **Zero runtime dependencies.** PHP 8.2+, WordPress 6.5+ — the floor is set by the Script Modules API (`wp_register_script_module()`, new in 6.5). The only API used above 6.5 is `wp_register_block_types_from_metadata_collection()` (6.8+), and `AssetLoader::register_block_manifest()` guards it with a per-block fallback for 6.5–6.7. +Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Codex). `rtcamp/wp-framework`: shared base contracts (interfaces, abstracts, traits) and small utilities consumed via Composer by every rtCamp plugin/theme skeleton. **Zero Composer runtime dependencies.** PHP 8.2+, WordPress 6.5+ — the floor is set by the Script Modules API (`wp_register_script_module()`, new in 6.5). The only API used above 6.5 is `wp_register_block_types_from_metadata_collection()` (6.8+), and `AssetLoader::register_block_manifest()` guards it with a per-block fallback for 6.5–6.7. `Encryptor` requires the OpenSSL PHP extension when used. ## Authoritative rules @@ -10,7 +10,7 @@ Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Cod ## Key principles (full detail in the files above) - **`inc/Contracts/` is public API.** Interfaces, abstracts, and their method signatures are consumed by every plugin/theme: a signature change breaks all of them. Treat such changes as breaking. -- **Zero runtime deps**: `composer.json` `require` holds only `php`; everything else is `require-dev`. +- **Zero Composer runtime deps**: `composer.json` `require` holds only `php`; everything else is `require-dev`. - **TDD**: failing PHPUnit test first (`tests/` mirrors `inc/`), then code. - **Tests run against real WordPress via wp-env** — no WP function mocking. `npm run wp-env start` then `npm run test:php` (a `pretest:php` hook runs `composer install` in the container first). WP-dependent tests extend `rtCamp\WPFramework\Tests\TestCase` (a `WP_UnitTestCase`); pure-logic tests can stay on `PHPUnit\Framework\TestCase`. CI runs a PHP × WP matrix (PHP 8.2+, WP 6.5+). - `declare( strict_types = 1 );`, full types, `@package`/`@since`, `static::` not `self::`, PSR-4 (`rtCamp\WPFramework\` → `inc/`). @@ -18,7 +18,7 @@ Tool-agnostic brief for AI coding agents (Claude Code, Copilot coding agent, Cod ## Structure -`inc/Contracts/{Interfaces,Abstracts,Traits}/` (the consumed contract surface), `inc/` root (`Container`, `AssetLoader`, `ComponentLoader`), `inc/Utils/`. `ai/` holds the canonical consumer instruction doc; `bin/` holds the sync tool. +`inc/Contracts/{Interfaces,Abstracts,Traits}/` (the consumed contract surface), `inc/` root (`Container`, `AssetLoader`, `ComponentLoader`, `TemplateLoader`), `inc/Utils/`. `ai/` holds the canonical consumer instruction doc; `bin/` holds the sync tool. ## This repo also ships tooling for consumers diff --git a/CHANGELOG.md b/CHANGELOG.md index 2435807..fc624ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Nothing yet. +### Documentation + +- Added implementor getting-started and maintainer workflow guides; corrected + Singleton, REST-controller, compatibility, and wp-env test guidance; expanded + loader, cache, feature-selector, timer, and utility API coverage. +- Added `docs/upgrading.md` (versioning promise and the 1.0.0 → 1.0.1 `Singleton` + migration) and `docs/troubleshooting.md` (symptom → cause for the framework's + exceptions, `_doing_it_wrong()` notices, and silent no-ops). +- Documented the real install path: the package is not on public Packagist, so + the consumer needs a VCS `repositories` entry and a `^1.0` constraint. +- Added a worked WP-CLI example to `docs/contracts.md`, the only contract that + had none, and a quick-look snippet to the README. +- Corrected the `Loader::load()` snippet in `docs/architecture.md` to match the + implementation, and the `AbstractSettingsPage` capability note in + `docs/abstracts.md` (the `option_page_capability_*` filter is unconditional). ## [1.0.1] - 2026-07-29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fefe6e0..60936f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,20 +17,22 @@ treated as stable and changes to it are considered breaking. ## Development setup ```bash -# 1. Install PHP dev dependencies +# Host tooling (PHPCS and PHPStan). composer install -# 2. Bring up WordPress for the integration tests (Docker required) -npm install -npm run wp-env start # starts @wordpress/env +# WordPress integration tests (Docker required). +npm ci +npm run wp-env start ``` ## Before you open a PR -Run the full check suite locally — all of it must exit `0`: +Run all three checks locally — all of them must exit `0`: ```bash -composer check # PHPCS (lint) + PHPStan (analyse) + PHPUnit (test) +composer lint +composer analyse +npm run test:php ``` Individual steps: @@ -39,17 +41,25 @@ Individual steps: composer lint # PHPCS against WordPress Coding Standards composer lint:fix # auto-fix fixable violations composer analyse # PHPStan static analysis -composer test # PHPUnit +npm run test:php # PHPUnit in the wp-env test container ``` -Tests run against real WordPress via `@wordpress/env`. Follow TDD: add a failing -test under `tests/` (which mirrors `inc/`) first, then the implementation. +Tests run against real WordPress via `@wordpress/env`. The `pretest:php` script +installs Composer dependencies inside the container before PHPUnit runs. +`composer test` is the lower-level host command and requires a separately +configured WordPress test suite and database; it is not the default local path. + +Follow TDD: add a failing test under `tests/` (which mirrors `inc/`) first, then +the implementation. See [docs/maintainers.md](docs/maintainers.md) for test-case, +contract-change, and documentation guidance. ## Pull request checklist -- [ ] `composer check` passes (lint + analyse + test, all green). +- [ ] `composer lint`, `composer analyse`, and `npm run test:php` pass. - [ ] New/changed behavior is covered by tests. - [ ] Any change to `inc/Contracts/` is flagged as breaking in the PR description. +- [ ] Contract changes are reflected in `ai/framework-php.instructions.md`. +- [ ] User-visible behavior is reflected in `README.md` or `docs/`. - [ ] A `CHANGELOG.md` entry is added under `## [Unreleased]`. - [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org/). diff --git a/README.md b/README.md index 8ae6563..9738bed 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,63 @@ `wp-framework` is a **library, not a plugin**. It ships contracts (interfaces, abstracts, traits) plus concrete loaders and utilities; consuming plugins and -themes build their features on top. **Zero runtime dependencies. PHP 8.2+.** +themes build their features on top. It has **zero Composer runtime dependencies**. + +Requirements: + +- PHP 8.2+ +- WordPress 6.5+ +- Composer +- The OpenSSL PHP extension when using `Encryptor` ## Install +Not on public Packagist — add the repository to the consuming project's +`composer.json`, then require it with a caret constraint: + +```json +{ + "repositories": [ + { "type": "vcs", "url": "https://github.com/rtCamp/wp-framework" } + ] +} +``` + ```bash -composer require rtcamp/wp-framework +composer require rtcamp/wp-framework:^1.0 ``` +(The `repositories` entry is unnecessary when the project already resolves this +package through an rtCamp-hosted Composer registry.) + PSR-4 autoloading: `rtCamp\WPFramework\` → `inc/`. +## Quick look + +```php +use rtCamp\WPFramework\Contracts\Abstracts\AbstractPostType; +use rtCamp\WPFramework\Contracts\Traits\Loader; + +final class ArticlePostType extends AbstractPostType { + public static function get_slug(): string { return 'article'; } + public function get_singular_label(): string { return __( 'Article', 'acme' ); } + public function get_plural_label(): string { return __( 'Articles', 'acme' ); } + public function get_menu_icon(): string { return 'dashicons-media-document'; } +} + +final class Main { + use Loader; + + public function boot(): void { + $this->load( [ ArticlePostType::class ] ); // instantiates + registers hooks + } +} +``` + +A registered, REST-enabled post type with no `register_post_type()` call and no +`init` hook written by hand. Full walkthrough in +[docs/getting-started.md](docs/getting-started.md). + ## What's inside - **Registration core** — the spine every consumer boots through: @@ -48,6 +95,9 @@ PSR-4 autoloading: `rtCamp\WPFramework\` → `inc/`. - `Cache` — typed wrapper over the WP object cache, group-namespaced, optional SWR - `FeatureSelector` + `FeatureSelectorSettingsPage` — a fail-closed feature-flag registry and its admin toggle page + - `Logger` — context-prefixed, `WP_DEBUG`-gated logging + - `Timer` — named request-scoped timers and laps + - `Transients` — prefix-namespaced transient storage The contract surface (`inc/Contracts/`) is the public API: every interface, abstract, and signature there is consumed by dependents, so changes to it are @@ -67,23 +117,33 @@ Start with [docs/index.md](docs/index.md), then: | Doc | What it covers | |---|---| +| [getting-started.md](docs/getting-started.md) | Install, bootstrap a plugin or theme, load a module, and share a service. | | [architecture.md](docs/architecture.md) | How a class becomes a live hook — the `Registrable` → `Loader` → `Container` flow. Read first. | | [contracts.md](docs/contracts.md) | The interfaces and traits in detail. | | [abstracts.md](docs/abstracts.md) | Cookbook for the `Abstract*` base classes. | | [loaders.md](docs/loaders.md) | `AssetLoader`, `ComponentLoader`, `TemplateLoader` and the theme-override hierarchy. | -| [utilities.md](docs/utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, and `Container`. | +| [utilities.md](docs/utilities.md) | `Encryptor`, `Cache`, feature flags, logging, transients, timers, and `Container`. | +| [upgrading.md](docs/upgrading.md) | What changes between releases and what a consumer has to do about it. | +| [troubleshooting.md](docs/troubleshooting.md) | Symptom → cause for the errors and silent no-ops the framework emits. | +| [ai-review-system.md](docs/ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. | +| [maintainers.md](docs/maintainers.md) | Development environment, tests, change checklist, and documentation maintenance. | ## Development ```bash -composer install # PHP dev dependencies -composer check # lint (PHPCS) + analyse (PHPStan) + test (PHPUnit) +composer install +npm ci +npm run wp-env start +composer lint +composer analyse +npm run test:php ``` -Tests run against real WordPress via [`@wordpress/env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/) -(`npm install && npm run wp-env start`). TDD: a failing test first (`tests/` mirrors -`inc/`), then the code. Conventions live in [AGENTS.md](AGENTS.md), shared across -all contributors and AI tools. +Tests run against real WordPress via [`@wordpress/env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/). +Use `npm run test:php`, which runs PHPUnit inside the wp-env test container; +`composer test` only works directly when a host WordPress test suite has been +configured. See [docs/maintainers.md](docs/maintainers.md) for the complete +workflow. ## Contributing diff --git a/ai/framework-php.instructions.md b/ai/framework-php.instructions.md index bbfd473..3fddef2 100644 --- a/ai/framework-php.instructions.md +++ b/ai/framework-php.instructions.md @@ -16,7 +16,7 @@ Decision order for a new class, **do NOT default to Singleton**: 2. **`Registrable` + `Shareable`**: only if another class must retrieve it via `get_shared()`. 3. **`Singleton`**: only the `Main` bootstrap. -Extend the framework abstracts; never hand-roll their job: `AbstractModule` and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole}`. +Extend the framework abstracts; never hand-roll their job: `AbstractModule`, `AbstractFeature`, and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole}`. Flag genuine contract/security violations, not style. Allow any correct implementation. diff --git a/docs/abstracts.md b/docs/abstracts.md index fef3cc8..045cb7c 100644 --- a/docs/abstracts.md +++ b/docs/abstracts.md @@ -226,8 +226,8 @@ Because it's a real `WP_REST_Controller`, all the core helper methods (`get_items_permissions_check()`, schema helpers, …) are available to override. > **Implementation note.** The base declares `register_routes()` as a concrete -> method that throws a "not implemented" `Exception` (the message is prefixed with -> the method name) rather than as `abstract`. The +> method that throws a `LogicException` naming the concrete class and method, +> rather than as `abstract`. The > effect is "you must override it," but the failure surfaces at **runtime** (when > `rest_api_init` fires), not at class-load time. Always provide your own > `register_routes()`. (An `abstract` method would catch a missing override at @@ -278,6 +278,14 @@ subclass — it re-declares the same menu seams). It registers on **three** hook base loops it and calls `register_setting( $this->get_option_group(), … )` for each. The option group defaults to the page slug. +`get_menu_slug()` also defaults to the page slug and can be overridden when the +menu slug depends on instance state. The base always filters +`option_page_capability_{group}` for this page's option group to return +`get_capability()`, so the menu, the render callback, and the `options.php` save +are authorized by the same capability. That matters when `get_capability()` is +lowered from its `manage_options` default: without the filter the page would +render for the lower capability but silently fail to save. + ```php final class SettingsPage extends AbstractSettingsPage { public static function get_slug(): string { return 'my-plugin-settings'; } diff --git a/docs/architecture.md b/docs/architecture.md index dbfab0b..0bbff6c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,17 +24,23 @@ debuggable by reading `Loader::load()` top to bottom. [`Loader::load()`](../inc/Contracts/Traits/Loader.php) is the heart of the framework. Given `class-string[]`, for each class it: -1. **Instantiates** it with `new $class_name()` — every loadable class must be +1. **Skips a duplicate** when the same class name already appeared in this load. +2. **Instantiates** it with `new $class_name()` — every loadable class must be constructible with no arguments (i.e. no *required* constructor parameters; all-optional is fine). -2. **Registers hooks** if it is `Registrable` — but first, if it is also +3. **Registers hooks** if it is `Registrable` — but first, if it is also `ConditionallyRegistrable`, it calls `can_register()` and skips registration when that returns `false`. -3. **Caches the instance** if it is `Shareable`, storing it in a `Container` +4. **Caches the instance** if it is `Shareable`, storing it in a `Container` keyed by class name. ```php foreach ( $classes as $class_name ) { + if ( isset( $seen[ $class_name ] ) ) { + continue; + } + $seen[ $class_name ] = true; + $instance = new $class_name(); if ( $instance instanceof Registrable ) { @@ -60,6 +66,9 @@ them, not to a global. Call `get_shared( $class_name )` on that same loader to retrieve one; calling it before `load()` — or for a class that wasn't `Shareable` — throws a `RuntimeException`. +Because another `load()` replaces the container, use one complete class list per +loader. A second call does not add to the previously shared set. + ## Modules: loaders that hold loaders Most skeletons don't hand the top-level loader a flat list of services. They hand @@ -135,7 +144,9 @@ they are not the same thing: default. - **`Singleton` trait** — global `ClassName::get_instance()` access with cloning and deserialization guarded. Use it only when something truly must be a process - global and you can't thread it through a loader. + global and you can't thread it through a loader. A class and its subclasses + share the trait's single storage slot, so do not resolve a child of a singleton + through `get_instance()`. If you can pass the object in a constructor instead, do that. The `ComponentLoader`/`TemplateLoader` pattern (a `Shareable` subclass fetched via diff --git a/docs/contracts.md b/docs/contracts.md index f6d1a71..a41b316 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -92,6 +92,63 @@ from a `Registrable`'s `register_hooks()` behind a `WP_CLI` check), using these methods to supply the name, description, and callback. It standardises the *shape* of a command across skeletons, not its registration. +The command itself is a plain static class: + +```php +use rtCamp\WPFramework\Contracts\Interfaces\CLICommand; + +final class ReindexCommand implements CLICommand { + public static function get_name(): string { + return 'acme reindex'; + } + + public static function get_description(): string { + return 'Rebuild the Acme search index.'; + } + + /** + * @param string[] $args Positional arguments. + * @param array $assoc_args Flags. + */ + public static function run( array $args, array $assoc_args ): void { + \WP_CLI::log( 'Reindexing…' ); + \WP_CLI::success( 'Done.' ); + } +} +``` + +Registration is the consumer's, and `ConditionallyRegistrable` is the natural +place for the `WP_CLI` check — the loader then skips the class entirely outside +WP-CLI instead of the class guarding itself: + +```php +use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; + +final class CliCommands implements ConditionallyRegistrable { + /** @var array> */ + private const COMMANDS = [ ReindexCommand::class ]; + + public function can_register(): bool { + return defined( 'WP_CLI' ) && \WP_CLI; + } + + public function register_hooks(): void { + foreach ( self::COMMANDS as $command ) { + \WP_CLI::add_command( + $command::get_name(), + [ $command, 'run' ], + [ 'shortdesc' => $command::get_description() ] + ); + } + } +} +``` + +`CliCommands` goes in a module's `get_classes()` like any other service. Because +`WP_CLI::add_command()` is available as soon as WP-CLI has bootstrapped, no +further hook is needed — registering directly from `register_hooks()` is correct +here, unlike the `Abstract*` classes that must wait for `init`. + ## Traits ### `Loader` @@ -103,7 +160,7 @@ gain the ability to load other classes. | Member | Visibility | Purpose | |---|---|---| -| `load( array $classes ): void` | `protected` | Instantiate each class; register hooks if `Registrable` (respecting `ConditionallyRegistrable`); cache if `Shareable`. Creates a fresh `Container` each call. | +| `load( array $classes ): void` | `protected` | Instantiate each unique class; register hooks if `Registrable` (respecting `ConditionallyRegistrable`); cache if `Shareable`. Creates a fresh `Container` each call. | | `get_shared( string $id ): object` | `public` | Return an instance previously cached as `Shareable`. Throws `RuntimeException` if `load()` hasn't run, or if `$id` was never cached as a `Shareable` in that load (not `Shareable`, or not among the loaded classes). | | `$container` | `private Container` | The per-load instance store. Not accessible to consumers — go through `get_shared()`. | @@ -111,6 +168,11 @@ Because `load()` is `protected`, only the class that `use`s the trait can start load — you can't load from outside. That's why the entry point is a consumer's own `Main` class and each `AbstractModule`, both of which `use Loader`. +Duplicate names in one class list are loaded once, preventing duplicate hook +registration. A subsequent call to `load()` creates a new container rather than +extending the old one, so instances shared by a previous call are no longer +retrievable from that loader. + ### `Singleton` [`inc/Contracts/Traits/Singleton.php`](../inc/Contracts/Traits/Singleton.php) @@ -129,12 +191,18 @@ trait Singleton { Points that matter: -- It uses **late static binding** (`static::$instance`, `new static()`), so each - class that uses the trait gets its **own** instance, not a shared one. This is - why the framework's house rule is "`static::`, never `self::`" — `self::` here - would collapse every singleton into one slot. +- It uses **late static binding** (`static::$instance`, `new static()`), allowing + the using class to override or initialize the protected storage. Unrelated + classes that each use the trait have separate properties, but a class and its + subclasses share one storage slot. Do not call `get_instance()` on a subclass + of a singleton: whichever side is resolved first occupies the slot for both. - The constructor is `protected` and empty; the using class overrides it to do setup. Direct `new` is blocked. +- `get_instance()` stores the instance after the constructor returns. If the + constructor performs work that can re-enter `get_instance()`—for example a + `Main` constructor that loads classes whose constructors reach back to + `Main`—assign `static::$instance = $this` as the constructor's first statement. + Otherwise the re-entrant call starts another construction. - `__clone()` and `__wakeup()` are `final` and emit `_doing_it_wrong()` — the instance can't be duplicated or revived through deserialization. - The trait's docblock states up front that singletons are an anti-pattern; diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..af3b637 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,198 @@ +# Getting started + +This guide shows the smallest complete integration: install the package, boot it +from a plugin or theme, group services in a module, and retrieve a deliberately +shared service. + +## Requirements + +- PHP 8.2 or newer +- WordPress 6.5 or newer +- Composer + +The package has no Composer runtime dependencies beyond PHP. It is nevertheless +a WordPress library: its registration classes and most utilities call WordPress +APIs. `Encryptor` additionally requires the OpenSSL PHP extension when used. + +## Install + +The package is not published on public Packagist. Add the repository to the +consuming project's `composer.json` first, then require it: + +```json +{ + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/rtCamp/wp-framework" + } + ] +} +``` + +```bash +composer require rtcamp/wp-framework:^1.0 +``` + +If the project is already wired to an rtCamp-hosted Composer registry that +serves this package, the `repositories` entry is unnecessary and +`composer require rtcamp/wp-framework:^1.0` is enough on its own. + +Pin with a caret constraint. `inc/Contracts/` is the public API and the project +follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), so `^1.0` +accepts additive releases and refuses the next major. Read +[upgrading.md](upgrading.md) before moving across a major. + +Composer exposes framework classes through the `rtCamp\WPFramework\` namespace. +The consuming plugin or theme remains responsible for requiring its own Composer +autoload file and starting its entry class. + +## 1. Create a service + +Use the narrowest contract that describes the service. Most hook-driven classes +only need `Registrable`: + +```php +' . esc_html__( 'Example is active.', 'acme-example' ) . '

'; + } +} +``` + +For post types, taxonomies, blocks, shortcodes, REST controllers, settings pages, +admin pages, and user roles, extend the matching `Abstract*` class instead of +repeating its registration plumbing. See [abstracts.md](abstracts.md). + +## 2. Group services in a module + +An `AbstractModule` is a loader for a related set of classes. Every listed class +must be constructible without required constructor arguments. + +```php +load( [ ContentModule::class ] ); + } +} + +Main::get_instance(); +``` + +### Theme entry point + +The same `Main` shape works in a theme. Require the theme's Composer autoloader +from `functions.php` and attach `boot()` to `after_setup_theme` instead of +`plugins_loaded`. + +The early `static::$instance = $this` assignment is important when construction +can re-enter `get_instance()`. A class using `Singleton` and its subclasses also +share one storage slot; do not call `get_instance()` on a subclass of a singleton. +See [contracts.md](contracts.md#singleton). + +## Sharing a service intentionally + +Classes are not retained by default. Add the `Shareable` marker only when another +class must retrieve the exact instance that was loaded: + +```php +use rtCamp\WPFramework\Contracts\Interfaces\Shareable; +use rtCamp\WPFramework\Utils\Cache; + +final class PluginCache extends Cache implements Shareable { + public function __construct() { + parent::__construct( 'acme-example' ); + } +} + +final class InfrastructureModule extends AbstractModule { + protected function get_classes(): array { + return [ PluginCache::class ]; + } + + public function cache(): PluginCache { + return $this->get_shared( PluginCache::class ); + } +} +``` + +`get_shared()` belongs to the loader that loaded the class. In this example the +cache is retrieved from `InfrastructureModule`, not from `Main`. If an outer +loader must retrieve the module itself, the module must also implement +`Shareable`. + +Prefer constructor injection when objects can be assembled directly. `Shareable` +is for loader-created objects that genuinely need later retrieval; it should not +be the default for every service. + +## What happens during load + +For each unique class name, the loader: + +1. constructs one instance; +2. calls `can_register()` for a `ConditionallyRegistrable`; +3. calls `register_hooks()` when registration is allowed; +4. stores the instance when it is `Shareable`. + +Duplicate class names in one list are ignored. Each call to `load()` creates a +fresh container, so a later call on the same loader replaces the previously +shared set. Prefer one load per loader with the complete class list. + +Continue with [architecture.md](architecture.md) for the lifecycle model and +[abstracts.md](abstracts.md) for implementation recipes. When something doesn't +register, start at [troubleshooting.md](troubleshooting.md). diff --git a/docs/index.md b/docs/index.md index a7ae2f6..98fc430 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,9 @@ Two rules define the whole package: 1. **A registration system.** A predictable way to turn a list of classes into live WordPress hooks — `Registrable`, the `Loader` trait, and the - `Container`. This is the spine; read [architecture.md](architecture.md) first. + `Container`. This is the spine; start with + [getting-started.md](getting-started.md), then read + [architecture.md](architecture.md). 2. **A library of base classes.** Ten `Abstract*` classes — most wrap one WordPress registration chore (a post type, a taxonomy, a block, a settings page, …); two are structural: `AbstractModule` groups services and @@ -27,19 +29,23 @@ Two rules define the whole package: across the child-theme → parent-theme → package hierarchy. See [loaders.md](loaders.md). 4. **Utilities & services.** Context-scoped helpers a consumer holds or shares: - `Encryptor`, `Cache`, and `FeatureSelector` (+ its settings page). See - [utilities.md](utilities.md). + `Encryptor`, `Cache`, `FeatureSelector` (+ its settings page), `Logger`, + `Transients`, and `Timer`. See [utilities.md](utilities.md). ## Map of the docs | Doc | What it covers | |---|---| +| [getting-started.md](getting-started.md) | A complete first integration: requirements, bootstrap, module, service, and shared-service retrieval. | | [architecture.md](architecture.md) | The mental model: how a class becomes a live hook. The `Registrable` → `Loader` → `Container` flow and where `Module` fits. Start here. | | [contracts.md](contracts.md) | Reference for the interfaces and traits: `Registrable`, `ConditionallyRegistrable`, `Shareable`, `CLICommand`, `Loader`, `Singleton`. | | [abstracts.md](abstracts.md) | Cookbook for the ten `Abstract*` base classes — what each is for, the methods to implement, the hook it wires, a minimal subclass. | | [loaders.md](loaders.md) | `AssetLoader`, `ComponentLoader`, `TemplateLoader` — the asset/render subsystem and the theme-override hierarchy they share. | -| [utilities.md](utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, `FeatureSelectorSettingsPage`, and `Container`. | -| [ai-review-system.md](ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. | +| [utilities.md](utilities.md) | `Encryptor`, `Cache`, `FeatureSelector`, its settings page, `Logger`, `Transients`, `Timer`, and `Container`. | +| [upgrading.md](upgrading.md) | What changes between releases, and what a consumer has to do about it. | +| [troubleshooting.md](troubleshooting.md) | Symptom → cause for the exceptions, `_doing_it_wrong()` notices, and silent no-ops the framework emits. | +| [ai-review-system.md](ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. *(maintainer/tooling doc)* | +| [maintainers.md](maintainers.md) | How to set up, test, verify, and document changes to this repository. *(maintainer doc)* | ## How a skeleton uses it (the one-paragraph version) diff --git a/docs/loaders.md b/docs/loaders.md index 016b1ab..65e7857 100644 --- a/docs/loaders.md +++ b/docs/loaders.md @@ -77,6 +77,12 @@ An asset is then addressed by a path **relative to `$assets_dir`, without the extension** — e.g. `register_script( 'my-app', 'app' )` looks for `//app.js`. +`get_base_dir()` returns the normalized, trailing-slashed package directory and +`get_assets_dir()` returns the relative assets directory. These are useful when +a subclass needs to resolve another build artifact. `handle( $name )` prefixes a +short handle with `static::HANDLE_PREFIX`; override that constant in a consumer +subclass to namespace all handles consistently. + ### Registration methods | Method | Registers | Returns | @@ -91,6 +97,10 @@ These **register**; they don't enqueue. Call `wp_enqueue_script()` / `wp_enqueue_style()` with the handle afterwards (the `ComponentLoader` does this for you for component assets). +Asset filenames may contain relative subdirectories but may not contain `..`. +The extension must be a bare suffix without a leading dot or path separator. +Unsafe paths are rejected before the filesystem is probed. + ### The `*.asset.php` manifest This is the convenience that makes `AssetLoader` worth using. The @@ -196,6 +206,22 @@ method precisely so a component can't reach back into the loader. It can forward - A missing component emits `_doing_it_wrong()` and renders nothing rather than throwing a fatal error. +### Component hooks + +With a context of `my-plugin`, the hooks use the following names and arguments: + +| Hook | Type | Arguments | +|---|---|---| +| `my-plugin/component_before_render` | action | `$name, $args, $context` | +| `my-plugin/component_after_render` | action | `$name, $args, $context` | +| `my-plugin/component_should_enqueue` | filter | `$enqueue, $name, $asset_type, $context` | +| `my-plugin/component_asset_handle` | filter | `$handle, $name, $asset_type, $context` | + +`$asset_type` is `style` or `script`. The should-enqueue filter must return a +boolean; the handle filter must return a string. Override +`get_enqueue_settings()` to change the default `script` and `style` values for +all renders from a loader. + --- ## TemplateLoader @@ -256,6 +282,23 @@ dominates the location. - A missing template is a silent no-op (`render()` echoes nothing, `locate()` returns `false`). +### Template hooks + +For a prefix of `my_plugin` and the default `/` separator: + +| Hook | Type | Arguments / return value | +|---|---|---| +| `my_plugin/get_template_part_{slug}` | action | `$slug, $name, $args` | +| `my_plugin/template_file_names` | filter | `array $templates, $slug, $name`; return candidate filenames | +| `my_plugin/template_args` | filter | `array $args, $slug, $name`; return arguments passed to the template | +| `my_plugin/template_paths` | filter | `array $paths`; return paths keyed by numeric priority | +| `my_plugin/located_template` | filter | `$located, array $templates`; return a path or `false` | + +The request action and argument filter run only through `render()`/`get()`; +`locate()` performs location filters without rendering. Exceptions thrown by a +component or template propagate to the caller. The `get()` methods clean up +their output buffer before rethrowing. + > **`TemplateLoader` vs. `ComponentLoader` — when to use which.** Use a > **component** for a reusable, self-contained UI fragment with its own scoped > CSS/JS and no dependence on the main query (a button, a card). Use a **template diff --git a/docs/maintainers.md b/docs/maintainers.md new file mode 100644 index 0000000..9e7d288 --- /dev/null +++ b/docs/maintainers.md @@ -0,0 +1,141 @@ +# Maintainer guide + +This guide covers work on `rtcamp/wp-framework` itself. For consuming the +library, start with [getting-started.md](getting-started.md). + +## Local environment + +You need PHP 8.2+, Composer, Node.js, Docker, and a Docker-compatible runtime. + +```bash +composer install +npm ci +npm run wp-env start +``` + +Composer installs host-side lint and static-analysis tools. `wp-env` provides the +real WordPress test environment; tests do not mock WordPress functions. + +## Run the checks + +Run these before opening a pull request: + +```bash +composer lint +composer analyse +npm run test:php +``` + +`npm run test:php` runs PHPUnit inside the wp-env `tests-cli` container. Its +`pretest:php` hook installs Composer dependencies in that container first. + +`composer test` invokes PHPUnit directly on the host. Use it only when the host +has a WordPress test suite and database configured through one of the paths +supported by `tests/bootstrap.php`, such as `WP_TESTS_DIR`. Merely starting +wp-env does not configure the host command. + +Coverage can be collected in wp-env with: + +```bash +npx wp-env start --xdebug=coverage +npm run test:php:coverage +``` + +Stop the environment when it is no longer needed: + +```bash +npm run wp-env stop +``` + +## Test conventions + +- Follow TDD: add a failing test, then implement the behavior. +- Mirror `inc/` under `tests/` for new classes and traits. +- Extend `rtCamp\WPFramework\Tests\TestCase` for code that calls WordPress APIs. +- A pure-logic test may extend `PHPUnit\Framework\TestCase`. +- Exercise actual WordPress registrations and registries rather than mocking + WordPress functions. +- Add reusable test-only classes under `tests/Fixtures/`. +- Keep tests order-independent; PHPUnit runs them in random order. + +CI runs PHPCS, PHPStan, and a PHP × WordPress integration-test matrix. The local +commands above are the closest single-environment equivalent. + +## Changing the library + +Before editing, identify which surface is affected: + +- `inc/Contracts/` contains interfaces, abstracts, and traits consumed by + plugins and themes. Signature changes here are breaking. +- `inc/` contains the container and concrete asset/render loaders. +- `inc/Utils/` contains reusable services and utilities. +- `ai/framework-php.instructions.md` is shipped to consumers as their canonical + framework and WordPress review guidance. +- `bin/sync-ai-instructions.js` refreshes and projects those instructions in + consuming repositories. + +For every behavior change: + +1. add or update the matching test; +2. implement the smallest compatible change; +3. update the relevant consumer or maintainer documentation; +4. add an entry under `CHANGELOG.md` → `[Unreleased]`; +5. run lint, analysis, and integration tests. + +For a change under `inc/Contracts/`, also: + +- call out the compatibility impact in the pull request; +- update `ai/framework-php.instructions.md` when consumer guidance or the + documented contract changes; +- check every abstract subclass signature and every documented example affected + by the change. + +Do not add a package to `composer.json` `require`; production dependencies are +limited to PHP. Development-only tooling belongs in `require-dev`. + +## Adding a class, interface, or trait + +- Use PSR-4 paths: `rtCamp\WPFramework\` maps to `inc/`. +- Add `declare( strict_types = 1 );`. +- Fully type parameters and return values. +- Add `@package` and `@since` documentation. +- Use `snake_case` methods and `PascalCase` classes. +- Use `static::`, not `self::`, where late static binding is intended. +- Add a matching test in the mirrored `tests/` path. +- Add the new API to the appropriate page under `docs/` and to the README/index + inventory when it is a new top-level capability. + +New `Abstract*` classes should implement `Registrable` and expose abstract +methods only for the values consumers must supply. Reuse `Loader` and `Container` +instead of introducing another registration or service-location mechanism. + +## Documentation sources + +The documentation has two audiences: + +- **Implementors:** `README.md` and `docs/{getting-started,architecture,contracts,abstracts,loaders,utilities}.md`. +- **Maintainers and contributors:** `CONTRIBUTING.md`, this guide, `AGENTS.md`, + and `.github/instructions/`. + +When implementation changes, search all of these locations for the affected +class or method. Source docblocks are detailed implementation references, but +they do not replace the task-oriented examples and behavior notes under `docs/`. + +The AI review distribution flow is documented separately in +[ai-review-system.md](ai-review-system.md). When changing its canonical rules, +keep the approximately 4,000-character Copilot instruction limit in mind. + +## Pull requests + +Use the repository pull-request template. A change is ready for review when: + +- lint, analysis, and integration tests pass; +- new or changed behavior has test coverage; +- compatibility implications are stated; +- user-facing documentation is current; +- `CHANGELOG.md` contains an `[Unreleased]` entry; +- commits follow Conventional Commits. + +Branching, release targeting, version selection, and package publication are not +defined here because the repository does not currently establish one complete, +authoritative release procedure. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..710d7b1 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,73 @@ +# Troubleshooting + +Symptom → cause for the failures the framework produces. Three shapes, and which +one you get is deliberate: + +- **Exceptions** for programming errors that must not be survivable (a missing + dependency, an unresolvable service). +- **`_doing_it_wrong()` notices** for developer mistakes WordPress convention + says to report rather than throw on. **These are only visible when `WP_DEBUG` + is on** — if a call "does nothing" in production, re-run it with `WP_DEBUG` + enabled before assuming the framework is silent. +- **Silent no-ops** where absence is a legitimate state (a template that isn't + there, an unregistered feature flag). + +## Registration & loading + +| Symptom | Cause | Fix | +|---|---|---| +| `RuntimeException: Cannot call get_shared() before load() has been called.` | `get_shared()` called on a loader whose `load()` hasn't run yet — often a service reaching for a shared instance during **construction**, while the loader is still mid-loop. | Retrieve on a hook that fires after the load, not in the constructor. | +| `RuntimeException: Instance "Acme\Foo" is not registered in the container.` | The class isn't `Shareable`, or it wasn't in the list this loader loaded, or a **later `load()` on the same loader replaced the container**. | Implement `Shareable`; call `get_shared()` on the loader that actually loaded the class (a module's services live on the module, not on `Main`); use one complete class list per loader. | +| `ArgumentCountError: Too few arguments to function …::__construct()` | A loaded class has a **required** constructor parameter. The loader always calls `new $class_name()`. | Make every parameter optional, or construct the object yourself and pass it in. | +| A class's hooks never fire | It is `ConditionallyRegistrable` and `can_register()` returned `false` — the instance is still constructed, only `register_hooks()` is skipped. | Check the condition (feature flag, `is_admin()`, `WP_CLI`). | +| Hook body runs twice | The same behavior is registered from two different classes, or one class is loaded by two different loaders. Duplicates **within one** class list are already de-duplicated. | Load the class from exactly one place. | +| `LogicException: Acme\Controller::register_routes() must be overridden.` on a REST request | `AbstractRESTController::register_routes()` was not overridden. The base throws instead of being `abstract`, so this surfaces when `rest_api_init` fires, not at class load. | Implement `register_routes()`. | +| Two singletons return the same object | A class and its subclass both resolved through `get_instance()`; the trait's `static::$instance` is **one storage slot** for both. | Don't call `get_instance()` on a subclass. Give each singleton its own `use Singleton;`, or use `Shareable` + `get_shared()`. See [upgrading.md](upgrading.md#100--101). | +| `Error: Access to undeclared static property …::$instance` | Running framework 1.0.0 with a constructor that assigns `static::$instance = $this`. | Upgrade to 1.0.1+, where that is the supported pattern. | + +## Assets, components & templates + +| Symptom | Cause | Fix | +|---|---|---| +| `_doing_it_wrong`: *Asset file "app.js" is missing. The asset will not be registered.* | No file at `//app.js`. The asset path is relative to the assets dir and **carries no extension** in the call. | Build first; check the `AssetLoader` constructor's `$base_dir` / `$assets_dir`. | +| `_doing_it_wrong`: *Asset manifest "…" is invalid; the file modification time will be used as the version.* | `app.asset.php` exists but doesn't return an array. | Regenerate the build. Harmless otherwise — registration continues with a `filemtime()` version. | +| `_doing_it_wrong`: *Block manifest file is missing. Blocks will not be registered.* | `register_block_manifest()` got a manifest path that doesn't resolve under the base dir. | Pass the path **relative to the base dir**, e.g. `build/blocks-manifest.php`. | +| Script registers but never loads | Registration is not enqueueing. | Call `wp_enqueue_script()` with the handle. `ComponentLoader` does this for component assets; `AssetLoader` never does. | +| Handles collide with another package | `HANDLE_PREFIX` left at its `wp-framework-` default. | Override the constant in the consumer's `AssetLoader` subclass. | +| `RuntimeException: Acme\Components requires an AssetLoader: inject one via the constructor or override get_asset_loader().` | The `ComponentLoader` subclass was constructed with no asset loader — typical when the framework `Loader` instantiates it with no arguments. | Build one in the subclass constructor, or override `get_asset_loader()` to resolve a shared instance lazily. | +| `_doing_it_wrong`: *Component "Foo" could not be resolved.* | No `Foo/Foo.php` under any layer of the hierarchy, **or the name was rejected**: names must match `^[A-Za-z0-9_-]+$` and be ≤128 characters. A slash or `..` is refused outright — that check is a security boundary, not a convenience. | Fix the path or the name. | +| A theme override isn't picked up | The render passed `allow_override => false`, or the override sits in a layer the loader doesn't search (a plugin-owned loader searches child → parent → package; a theme's own loader collapses the redundant layers). | Drop the option, or place the file in a searched layer. | +| Overrides stop resolving after a theme or blog switch | The hierarchy and component metadata are memoised per request, and both depend on the active theme. | Call `clear_cache()` after `switch_theme()` / `switch_to_blog()` on a long-lived (`Shareable`) loader. | +| `render()` outputs nothing, no notice | A missing **template** is a deliberate silent no-op (`locate()` returns `false`). | Check with `locate( $slug, $name )`. | +| Wrong template wins | Resolution puts the **name** in the outer loop, so a `{slug}-{name}.php` in the package beats a generic `{slug}.php` in the theme — matching core's `locate_template()` precedence. | Override the specific variant, not the generic one. | + +## Utilities + +| Symptom | Cause | Fix | +|---|---|---| +| `InvalidArgumentException: Encryptor only supports GCM ciphers …` | A non-GCM cipher was passed. The stored `IV ‖ tag ‖ ciphertext` layout is GCM-specific. | Use `aes-256-gcm` (the default) or another `-gcm` cipher. | +| `RuntimeException: No encryption key provided. …` | Constructed with an empty key and `key()` not overridden. | Pass a key, or override the protected `key()` seam. | +| `_doing_it_wrong`: *OpenSSL extension is not loaded.* and `encrypt()`/`decrypt()` return `false` | The OpenSSL PHP extension is missing on the host. | Install/enable it. `Encryptor` is the only part of the package that needs it. | +| `decrypt()` returns `false` | Tampered or truncated ciphertext (GCM authentication failed), non-base64 input, or the wrong key. **`false` is a return value, not an exception** — always check it. | Verify the key domain; treat a failure as untrusted data. | +| Same value encrypts to a different blob every time | Correct: a fresh random IV per call. | Never compare ciphertexts for equality; decrypt and compare plaintext. | +| A feature flag is always off | `is_enabled()` is fail-closed and returns `false` for an unregistered flag **silently** — usually a typo, or `register()` running after the check. | Register before checking; use the exact registered slug. | +| `_doing_it_wrong`: *Feature flag "x" collides with already-registered "y" …* | Two slugs normalize to the same storage key. The first registration is kept. | Rename one. | +| `_doing_it_wrong`: *Feature flag "x" is not registered; enable() ignored.* | `enable()` / `disable()` called for an unregistered flag. | Register it first. | +| A toggle on the settings page won't change | The flag is locked by a PHP constant, which always wins over the stored value. The checkbox renders disabled and the stored value is preserved. | Remove the constant from `wp-config.php`. Note `'false'` (the string) is treated as `false`. | +| SWR still stampedes across workers | `remember_swr()` locking needs a **persistent** object cache (Redis, Memcached). With WordPress's default request-local cache the lock isn't shared between PHP workers. | Deploy a persistent backend; without one the API still works as get-or-set. | +| A deleted cache key comes back stale | `delete()` removes the primary key only — `{key}_stale` and `{key}_lock` survive. | `flush_group()` to invalidate an SWR entry completely. | +| Nothing appears in the log | `Logger` writes only when logging is enabled, which tracks `WP_DEBUG` by default. | Enable `WP_DEBUG`, or override the protected `is_enabled()` seam. | +| Two modules overwrite each other's transients | Unprefixed `set_transient()` calls somewhere. `Transients` exists precisely to namespace them. | Construct one `Transients` per module with its own prefix. | +| `_doing_it_wrong`: *Timer "x" has already been started / was never started / has already been stopped* | Timer misuse. Reads are silent: `get()` returns `null` for an unknown label. | Share the same `Timer` **instance** across scopes (register it `Shareable`); a new instance has no timers. | + +## Environment + +| Symptom | Cause | Fix | +|---|---|---| +| `Class "rtCamp\WPFramework\…" not found` | The consumer's `vendor/autoload.php` was never required, or the package resolved from a stale `vendor/`. | Require the autoloader in the plugin/theme entry point; `composer update rtcamp/wp-framework`. | +| Composer can't find the package | It is not on public Packagist. | Add the VCS `repositories` entry — see [getting-started.md](getting-started.md#install). | +| A missing abstract method only surfaces at runtime | The subclass doesn't implement everything the abstract declares. | Run PHPStan in the consuming package; it catches contract breaks before a request does. | + +Still stuck? The classes are small and heavily commented — `Loader::load()` in +[`inc/Contracts/Traits/Loader.php`](../inc/Contracts/Traits/Loader.php) explains +the whole boot in one screen. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..bdfd482 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,74 @@ +# Upgrading + +What changes between releases of `rtcamp/wp-framework`, and what a consuming +plugin or theme has to do about it. [`CHANGELOG.md`](../CHANGELOG.md) is the +complete per-release record; this page carries only the entries that require a +code change on the consumer side. + +## Versioning promise + +The package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and the surface that version numbers describe is `inc/Contracts/` — every +interface, abstract, trait, and public method signature under it. + +| Change | Version bump | Consumer impact | +|---|---|---| +| Renamed or re-signatured member of `inc/Contracts/` | major | Subclasses must be updated. | +| Removed public method or class anywhere in `inc/` | major | Callers must be updated. | +| New abstract, interface, utility, or optional method | minor | None; adopt when useful. | +| New `abstract` method on an existing abstract | major | Every subclass must implement it. | +| Behavior fix inside an existing method | patch | Usually none — read the entry. | + +Pin with `^1.0` so Composer takes minors and patches and refuses the next major. +Read this page and the changelog before widening a constraint across a major. + +## Upgrade routine + +```bash +composer update rtcamp/wp-framework +composer lint && composer analyse # in the consuming package +``` + +Then run the consumer's own test suite. Static analysis catches the majority of +contract breaks (a missing abstract implementation, a changed signature) before +runtime does. + +## 1.0.0 → 1.0.1 + +**Affects:** any class using the `Singleton` trait. + +1.0.0 stored singleton instances in a private, class-string-keyed map. 1.0.1 +restored the ecosystem-standard `protected static $instance` storage, written by +`get_instance()` once the constructor returns. + +Two consequences: + +- **Early self-assignment works again, and is the supported pattern.** A + constructor that does work able to re-enter `get_instance()` — a `Main` that + loads classes whose constructors call `Main::get_instance()` — must publish + itself first: + + ```php + protected function __construct() { + static::$instance = $this; // before any work that can re-enter + add_action( 'plugins_loaded', [ $this, 'boot' ] ); + } + ``` + + On 1.0.0 this fataled with *"Access to undeclared static property"*. If that + line was removed as a 1.0.0 workaround, restore it. + +- **A class and its subclasses share one storage slot.** This is the trade-off + of the trait's single static property, and it is documented on the trait. Do + not call `get_instance()` on a subclass of a class that uses `Singleton` — + whichever side resolves first occupies the slot for both. Give each singleton + its own `use Singleton;`, or prefer `Shareable` + `get_shared()`. + +No other 1.0.1 change is consumer-visible. See +[contracts.md](contracts.md#singleton) for the full trait reference. + +## When an upgrade breaks something + +Framework failures are mostly loud — see +[troubleshooting.md](troubleshooting.md) for the symptom → cause table, then the +changelog entry for the release you moved to. diff --git a/docs/utilities.md b/docs/utilities.md index 120a554..2b48b83 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -24,6 +24,10 @@ collide. for sensitive values before they go into the database (API tokens, secrets). **AES-256-GCM** by default. +The OpenSSL PHP extension must be loaded when `encrypt()` or `decrypt()` is +called. If it is unavailable, the method reports incorrect usage and returns +`false`. + GCM is *authenticated*: it produces an auth tag that makes tampering detectable, so a modified ciphertext fails to decrypt instead of silently returning garbage. The class hard-rejects any cipher whose name doesn't end in `-gcm` (the stored @@ -65,6 +69,20 @@ $nav = $cache->remember( 'nav_items', fn() => build_nav(), 'theme', 300 ); the other services, register a `Cache` instance as `Shareable` in a consumer's container, or extend it to change the backend behaviour. +The direct wrapper methods are also available: + +| Method | Behavior | +|---|---| +| `get( $key, $group = '', $force = false, &$found = null )` | Read a value; `$found` distinguishes a miss from a stored falsy value. | +| `set( $key, $value, $group = '', $expiration = 0 )` | Store a serializable value; `0` means no expiry. | +| `delete( $key, $group = '' )` | Delete one key. It does not delete SWR companion keys. | +| `flush_group( $group )` | Flush the namespaced group when the active cache backend supports group flushing; otherwise return `false`. | +| `remember( $key, $callback, $group = '', $expiration = 0 )` | Return a hit or synchronously generate and store a miss. | +| `remember_swr( $key, $callback, $group = '', $expiration = 0 )` | Add stale data and locking around an expensive regeneration. | + +With context `my-plugin`, group `posts` becomes `my-plugin:posts`, and the empty +group becomes `my-plugin`. Override `resolve_group()` to change that scheme. + For hot keys where a simultaneous miss would stampede the backend, use `remember_swr( $key, $callback, $group, $expiration )`. On expiry one caller takes a short lock and regenerates the value in the foreground while every other caller @@ -79,6 +97,17 @@ It keeps two companion entries — `{key}_stale` (the fallback, stored at roughl the TTL) and `{key}_lock` — so avoid passing a `$key` that already ends in `_stale` or `_lock`. +On a stale hit, all callers—including the caller that wins the lock and performs +the synchronous regeneration—receive the stale value for that request. The new +value is stored for subsequent requests. On a cold start with neither fresh nor +stale data, the lock winner receives the generated value. + +Cross-process stampede protection requires a persistent object-cache backend +such as Redis or Memcached. With WordPress's default request-local cache, the API +still behaves as a get-or-set helper, but locks are not shared across PHP workers. +For complete invalidation of an SWR entry, flush its group; deleting the primary +key leaves its `_stale` and `_lock` companions intact. + ## Logger [`inc/Utils/Logger.php`](../inc/Utils/Logger.php) — a PSR-3-*style* logger that writes @@ -139,9 +168,8 @@ $store->delete( 'user_count' ); [`inc/Utils/FeatureSelector.php`](../inc/Utils/FeatureSelector.php) — a feature-flag registry with per-context toggle storage. It is **fail-closed**: -only registered flags resolve, and an unregistered or mistyped slug always -returns `false` from `is_enabled()`, so a typo can't accidentally run a feature -that doesn't exist. +only a key produced by a registered flag can resolve, and an unknown normalized +key returns `false` from `is_enabled()`. ```php $features = new FeatureSelector( 'my-plugin' ); @@ -159,6 +187,31 @@ For a registered flag the lookup precedence is: 3. **default `true`** — features ship on. The selector exists to turn things *off*, not on. +### Registry and state API + +| Method | Behavior | +|---|---| +| `register( $features )` | Accept one slug, a list of slugs, or a `slug => metadata` map. First registration wins. | +| `is_enabled( $flag )` | Resolve a registered flag through constant, stored value, then default. | +| `enable( $flag )` / `disable( $flag )` | Persist a registered flag's state; return `false` and warn for an unknown flag. | +| `get_registered()` | Return registered slugs in registration order. | +| `get_features()` | Return metadata keyed by original slug. | +| `get_context()` | Return the constructor context without normalization. | +| `shared_option_key()` | Return the single option holding this context's flags. | +| `flag_key( $flag )` | Return the normalized, dash-preserving storage key. | +| `constant_name( $flag )` | Return the PHP constant used to lock the flag. | + +For context `my-plugin` and flag `dark-mode`, the defaults are option +`my_plugin_features`, array key `dark-mode`, and constant +`MY_PLUGIN_FEATURE_DARK_MODE`. Registration detects collisions after key +normalization and keeps the first registration. Because lookups use that same +normalized key, spelling variants that normalize identically refer to the same +registered flag; callers should nevertheless use the original registered slug. + +A defined constant always wins over the stored toggle. The string `'false'` is +treated as false to handle a common `wp-config.php` mistake; other values use +normal PHP boolean conversion. + ## FeatureSelectorSettingsPage [`inc/Utils/FeatureSelectorSettingsPage.php`](../inc/Utils/FeatureSelectorSettingsPage.php) @@ -187,6 +240,12 @@ final class MyFeaturesPage extends FeatureSelectorSettingsPage { It's the ready-made UI for the toggles `FeatureSelector` reads — register it like any other `Registrable`. +The page registers a single array option with a sanitization callback. A flag +locked by a PHP constant renders as disabled, and saving the page preserves its +previous stored value. `register_fields()` runs only on `admin_init`; the setting +itself is also registered on `rest_api_init`, where the wp-admin field helpers +are unavailable. The render method performs its own capability check. + ## Timer [`inc/Utils/Timer.php`](../inc/Utils/Timer.php) — named timing segments that persist @@ -204,6 +263,21 @@ $elapsed = $timer->stop( 'render' ); // float seconds $all = $timer->get_all(); // every timer, with computed elapsed ``` +`get( $label )` returns `null` for an empty or unknown label. Otherwise it +returns: + +```php +[ + 'start' => 0.0, // absolute microtime value + 'end' => null, // absolute microtime value, or null while running + 'elapsed' => 0.0, // seconds since start + 'laps' => [ 'after_query' => 0.0 ], // seconds since start, keyed by lap name +] +``` + +`get_all()` returns that shape keyed by timer label. All running timers in one +`get_all()` call use the same time snapshot. + - **Instance-based, not a singleton.** The start-here / stop-there pattern shares state by sharing the *instance*: register one as `Shareable` in the consumer's container (the same pattern as `Cache`) so every hook resolves