From 9fd56529aa974485dd35fc45749cf58f169fab0e Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 23 Jul 2026 13:23:54 +0530 Subject: [PATCH 1/2] feat(contracts): add AbstractAbility and AbstractAbilityRegistrar Telemetry-agnostic base classes for the WordPress Abilities API (6.9+), lifted from the shape proven in rtcamp/wp-dev-tools (rtCamp/wp-devtools#12, item 1). AbstractAbility declares the pieces of an ability and maps them to wp_register_ability() args; AbstractAbilityRegistrar (a Registrable) hooks wp_abilities_api_categories_init / wp_abilities_api_init, registers the shared category idempotently, and loops its abilities. Both callbacks are wrapped in defaulted closures because core invokes them with zero arguments when no input schema is declared. category_description() is abstract: core rejects categories without a non-empty description. On cores older than 6.9 the hooks never fire, so the registrar is inert and the package's WordPress floor is unchanged. --- inc/Contracts/Abstracts/AbstractAbility.php | 163 ++++++++++ .../Abstracts/AbstractAbilityRegistrar.php | 124 ++++++++ .../AbstractAbilityRegistrarTest.php | 300 ++++++++++++++++++ .../Abstracts/AbstractAbilityTest.php | 191 +++++++++++ 4 files changed, 778 insertions(+) create mode 100644 inc/Contracts/Abstracts/AbstractAbility.php create mode 100644 inc/Contracts/Abstracts/AbstractAbilityRegistrar.php create mode 100644 tests/Contracts/Abstracts/AbstractAbilityRegistrarTest.php create mode 100644 tests/Contracts/Abstracts/AbstractAbilityTest.php diff --git a/inc/Contracts/Abstracts/AbstractAbility.php b/inc/Contracts/Abstracts/AbstractAbility.php new file mode 100644 index 0000000..c0d308a --- /dev/null +++ b/inc/Contracts/Abstracts/AbstractAbility.php @@ -0,0 +1,163 @@ + Input schema. + */ + abstract protected function input_schema(): array; + + /** + * Return the JSON Schema describing the ability output. + * + * Return an empty array to leave the output unspecified; args() then + * omits the key entirely. + * + * @return array Output schema. + */ + abstract protected function output_schema(): array; + + /** + * Execute the ability. + * + * @param mixed $input Input validated against the input schema. + * + * @return array|\WP_Error Result data, or an error. + */ + abstract public function execute( mixed $input ): array|\WP_Error; + + /** + * Decide whether the current user may execute the ability. + * + * Defaults to `manage_options` — fail-closed, administrators only. + * Override to gate on a different capability or on the input. + * + * @param mixed $input Input the ability is being called with. + * + * @return bool|\WP_Error True to allow; false or a WP_Error to deny. + */ + protected function permission( mixed $input = null ): bool|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $input is the override seam; the default gate ignores it. + return current_user_can( 'manage_options' ); + } + + /** + * Return the ability meta. + * + * Defaults to empty, which keeps the Abilities API defaults — not exposed + * over REST, no MCP flag. Override to opt in, e.g. + * `[ 'show_in_rest' => true ]`. + * + * @return array Meta. + */ + protected function meta(): array { + return []; + } + + /** + * Build the registration arguments for wp_register_ability(). + * + * The schema and meta keys are included only when non-empty, so the + * Abilities API applies its own defaults otherwise. Both callbacks are + * wrapped in closures with a defaulted parameter: when an ability + * declares no input schema, core invokes its callbacks with no arguments + * at all, and the wrappers keep that call safe for the required + * execute() signature (and keep permission() protected). + * + * @return array Registration args. + */ + public function args(): array { + $args = [ + 'label' => $this->label(), + 'description' => $this->description(), + 'category' => $this->category(), + 'execute_callback' => fn ( mixed $input = null ): array|\WP_Error => $this->execute( $input ), + 'permission_callback' => fn ( mixed $input = null ): bool|\WP_Error => $this->permission( $input ), + ]; + + $input_schema = $this->input_schema(); + if ( [] !== $input_schema ) { + $args['input_schema'] = $input_schema; + } + + $output_schema = $this->output_schema(); + if ( [] !== $output_schema ) { + $args['output_schema'] = $output_schema; + } + + $meta = $this->meta(); + if ( [] !== $meta ) { + $args['meta'] = $meta; + } + + return $args; + } +} diff --git a/inc/Contracts/Abstracts/AbstractAbilityRegistrar.php b/inc/Contracts/Abstracts/AbstractAbilityRegistrar.php new file mode 100644 index 0000000..51e460f --- /dev/null +++ b/inc/Contracts/Abstracts/AbstractAbilityRegistrar.php @@ -0,0 +1,124 @@ +category_slug() ) ) { + return; + } + + wp_register_ability_category( + $this->category_slug(), + [ + 'label' => $this->category_label(), + 'description' => $this->category_description(), + ] + ); + } + + /** + * Registers every ability the registrar manages. + */ + public function register_abilities(): void { + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + $this->before_register(); + + foreach ( $this->abilities() as $ability ) { + wp_register_ability( $ability->name(), $ability->args() ); + } + } + + /** + * Return the slug of the category the abilities are grouped under. + * + * @return string Category slug. + */ + abstract protected function category_slug(): string; + + /** + * Return the abilities to register. + * + * @return AbstractAbility[] Ability instances. + */ + abstract protected function abilities(): array; + + /** + * Return the category description. + * + * The Abilities API rejects a category without a non-empty description, + * so there is no default. Written for the caller browsing the category — + * for MCP-exposed abilities that caller is an AI agent. + * + * @return string Category description. + */ + abstract protected function category_description(): string; + + /** + * Return the human-readable category label. + * + * Defaults to a title-cased version of the slug ("my-plugin" → "My Plugin"). + * Override to provide a more descriptive name. + * + * @return string Category label. + */ + protected function category_label(): string { + return ucwords( str_replace( [ '-', '_' ], ' ', $this->category_slug() ) ); + } + + /** + * Runs immediately before the abilities are registered. + * + * Empty by default. Override for one-time preparation the abilities + * depend on — ensuring storage exists, priming options, and so on. + */ + protected function before_register(): void {} +} diff --git a/tests/Contracts/Abstracts/AbstractAbilityRegistrarTest.php b/tests/Contracts/Abstracts/AbstractAbilityRegistrarTest.php new file mode 100644 index 0000000..36e2266 --- /dev/null +++ b/tests/Contracts/Abstracts/AbstractAbilityRegistrarTest.php @@ -0,0 +1,300 @@ +markTestSkipped( 'The Abilities API requires WordPress 6.9+.' ); + } + + $this->reset_abilities_registries(); + } + + protected function tearDown(): void { + $this->reset_abilities_registries(); + + parent::tearDown(); + } + + /** + * Null both core registry singletons so each test starts from a cold, + * uninitialized Abilities API. + */ + private function reset_abilities_registries(): void { + foreach ( [ \WP_Abilities_Registry::class, \WP_Ability_Categories_Registry::class ] as $registry ) { + if ( ! class_exists( $registry ) ) { + continue; + } + + $property = new \ReflectionProperty( $registry, 'instance' ); + $property->setAccessible( true ); + $property->setValue( null, null ); + } + } + + /** + * Initialize the Abilities API exactly like production: the first registry + * access fires wp_abilities_api_categories_init, then wp_abilities_api_init. + */ + private function init_abilities_api(): void { + \WP_Abilities_Registry::get_instance(); + } + + /** + * Minimal concrete AbstractAbility. + * + * @param string $name Ability name. + * @param string $category Category slug. + */ + private function make_ability( string $name, string $category = 'test-plugin' ): AbstractAbility { + return new class( $name, $category ) extends AbstractAbility { + public function __construct( + private readonly string $ability_name, + private readonly string $ability_category + ) {} + + public function name(): string { + return $this->ability_name; + } + + protected function label(): string { + return 'Do Thing'; + } + + protected function description(): string { + return 'Does the thing.'; + } + + protected function category(): string { + return $this->ability_category; + } + + protected function input_schema(): array { + return [ + 'type' => 'object', + 'properties' => [ 'message' => [ 'type' => 'string' ] ], + ]; + } + + protected function output_schema(): array { + return []; + } + + public function execute( mixed $input ): array|\WP_Error { + return [ 'received' => $input ]; + } + }; + } + + /** + * Concrete AbstractAbilityRegistrar with injectable seams. + * + * The Abilities API rejects empty category descriptions, so the abstract + * category_description() always returns a real string here. + * + * @param string $slug Category slug. + * @param array $abilities Abilities to register. + * @param string $label Optional category_label() override. + * @param string $description Category description. + */ + private function make_registrar( + string $slug, + array $abilities, + string $label = '', + string $description = 'Tools for testing.' + ): AbstractAbilityRegistrar { + return new class( $slug, $abilities, $label, $description ) extends AbstractAbilityRegistrar { + public function __construct( + private readonly string $slug, + private readonly array $ability_list, + private readonly string $label_override, + private readonly string $desc + ) {} + + protected function category_slug(): string { + return $this->slug; + } + + protected function abilities(): array { + return $this->ability_list; + } + + protected function category_label(): string { + return '' !== $this->label_override ? $this->label_override : parent::category_label(); + } + + protected function category_description(): string { + return $this->desc; + } + }; + } + + public function test_implements_registrable(): void { + $this->assertInstanceOf( Registrable::class, $this->make_registrar( 'test-plugin', [] ) ); + } + + public function test_register_hooks_adds_both_actions(): void { + $registrar = $this->make_registrar( 'test-plugin', [] ); + $registrar->register_hooks(); + + $this->assertSame( 10, has_action( 'wp_abilities_api_categories_init', [ $registrar, 'register_category' ] ) ); + $this->assertSame( 10, has_action( 'wp_abilities_api_init', [ $registrar, 'register_abilities' ] ) ); + } + + public function test_ability_is_registered_and_retrievable(): void { + $this->make_registrar( 'test-plugin', [ $this->make_ability( 'test-plugin/do-thing' ) ] )->register_hooks(); + + $this->init_abilities_api(); + + $registered = wp_get_ability( 'test-plugin/do-thing' ); + + $this->assertInstanceOf( \WP_Ability::class, $registered ); + $this->assertSame( 'Do Thing', $registered->get_label() ); + $this->assertSame( 'test-plugin', $registered->get_category() ); + } + + public function test_category_is_registered_with_label_and_description(): void { + $this->make_registrar( 'test-plugin', [], 'Test Tools', 'Tools for testing.' )->register_hooks(); + + $this->init_abilities_api(); + + $this->assertTrue( wp_has_ability_category( 'test-plugin' ) ); + + $category = wp_get_ability_category( 'test-plugin' ); + + $this->assertInstanceOf( \WP_Ability_Category::class, $category ); + $this->assertSame( 'Test Tools', $category->get_label() ); + $this->assertSame( 'Tools for testing.', $category->get_description() ); + } + + public function test_default_category_label_title_cases_slug(): void { + $this->make_registrar( 'my-cool-plugin', [] )->register_hooks(); + + $this->init_abilities_api(); + + $category = wp_get_ability_category( 'my-cool-plugin' ); + + $this->assertInstanceOf( \WP_Ability_Category::class, $category ); + $this->assertSame( 'My Cool Plugin', $category->get_label() ); + } + + public function test_category_registration_is_idempotent_across_registrars(): void { + $this->make_registrar( 'test-plugin', [], 'First Label' )->register_hooks(); + $this->make_registrar( 'test-plugin', [], 'Second Label' )->register_hooks(); + + $this->init_abilities_api(); + + // The first registrar wins; the second skips its wp_register_ability_category() + // call instead of triggering a duplicate-registration notice. + $category = wp_get_ability_category( 'test-plugin' ); + + $this->assertInstanceOf( \WP_Ability_Category::class, $category ); + $this->assertSame( 'First Label', $category->get_label() ); + } + + public function test_registrar_registers_multiple_abilities(): void { + $this->make_registrar( + 'test-plugin', + [ + $this->make_ability( 'test-plugin/first-thing' ), + $this->make_ability( 'test-plugin/second-thing' ), + ] + )->register_hooks(); + + $this->init_abilities_api(); + + $this->assertInstanceOf( \WP_Ability::class, wp_get_ability( 'test-plugin/first-thing' ) ); + $this->assertInstanceOf( \WP_Ability::class, wp_get_ability( 'test-plugin/second-thing' ) ); + } + + public function test_before_register_runs_before_abilities_are_read(): void { + $log = new \ArrayObject(); + $registrar = new class( $log, $this->make_ability( 'test-plugin/do-thing' ) ) extends AbstractAbilityRegistrar { + public function __construct( + private readonly \ArrayObject $log, + private readonly AbstractAbility $ability + ) {} + + protected function category_slug(): string { + return 'test-plugin'; + } + + protected function category_description(): string { + return 'Tools for testing.'; + } + + protected function before_register(): void { + $this->log->append( 'before_register' ); + } + + protected function abilities(): array { + $this->log->append( 'abilities' ); + return [ $this->ability ]; + } + }; + $registrar->register_hooks(); + + $this->init_abilities_api(); + + $this->assertSame( [ 'before_register', 'abilities' ], $log->getArrayCopy() ); + } + + public function test_execute_round_trip_as_administrator(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->make_registrar( 'test-plugin', [ $this->make_ability( 'test-plugin/do-thing' ) ] )->register_hooks(); + + $this->init_abilities_api(); + + $result = wp_get_ability( 'test-plugin/do-thing' )->execute( [ 'message' => 'hi' ] ); + + $this->assertSame( [ 'received' => [ 'message' => 'hi' ] ], $result ); + } + + public function test_execute_is_denied_below_manage_options(): void { + $this->make_registrar( 'test-plugin', [ $this->make_ability( 'test-plugin/do-thing' ) ] )->register_hooks(); + + $this->init_abilities_api(); + + $ability = wp_get_ability( 'test-plugin/do-thing' ); + $this->assertInstanceOf( \WP_Ability::class, $ability ); + + wp_set_current_user( 0 ); + $anonymous = $ability->execute( [ 'message' => 'hi' ] ); + $this->assertInstanceOf( \WP_Error::class, $anonymous ); + $this->assertSame( 'ability_invalid_permissions', $anonymous->get_error_code() ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + $subscriber = $ability->execute( [ 'message' => 'hi' ] ); + $this->assertInstanceOf( \WP_Error::class, $subscriber ); + $this->assertSame( 'ability_invalid_permissions', $subscriber->get_error_code() ); + } +} diff --git a/tests/Contracts/Abstracts/AbstractAbilityTest.php b/tests/Contracts/Abstracts/AbstractAbilityTest.php new file mode 100644 index 0000000..31788ef --- /dev/null +++ b/tests/Contracts/Abstracts/AbstractAbilityTest.php @@ -0,0 +1,191 @@ +ability_name; + } + + protected function label(): string { + return 'Do Thing'; + } + + protected function description(): string { + return 'Does the thing.'; + } + + protected function category(): string { + return $this->ability_category; + } + + protected function input_schema(): array { + return $this->input; + } + + protected function output_schema(): array { + return $this->output; + } + + protected function meta(): array { + return $this->meta_override; + } + + protected function permission( mixed $input = null ): bool|\WP_Error { + if ( null !== $this->permission_override ) { + return ( $this->permission_override )( $input ); + } + + return parent::permission( $input ); + } + + public function execute( mixed $input ): array|\WP_Error { + return [ 'received' => $input ]; + } + }; + } + + public function test_args_maps_label_description_category_and_callbacks(): void { + $ability = $this->make_ability(); + $args = $ability->args(); + + $this->assertSame( 'Do Thing', $args['label'] ); + $this->assertSame( 'Does the thing.', $args['description'] ); + $this->assertSame( 'test-plugin', $args['category'] ); + $this->assertInstanceOf( \Closure::class, $args['execute_callback'] ); + $this->assertInstanceOf( \Closure::class, $args['permission_callback'] ); + $this->assertSame( [ 'received' => [ 'x' => 1 ] ], $args['execute_callback']( [ 'x' => 1 ] ) ); + } + + public function test_callbacks_are_safe_to_invoke_without_arguments(): void { + // Core invokes both callbacks with zero arguments when the ability + // declares no input schema; the closure wrappers absorb that. + $args = $this->make_ability()->args(); + + $this->assertSame( [ 'received' => null ], $args['execute_callback']() ); + $this->assertFalse( $args['permission_callback']() ); + } + + public function test_args_includes_schemas_when_provided(): void { + $input_schema = [ + 'type' => 'object', + 'properties' => [ 'message' => [ 'type' => 'string' ] ], + ]; + $output_schema = [ 'type' => 'object' ]; + + $args = $this->make_ability( input_schema: $input_schema, output_schema: $output_schema )->args(); + + $this->assertSame( $input_schema, $args['input_schema'] ); + $this->assertSame( $output_schema, $args['output_schema'] ); + } + + public function test_args_omits_empty_schemas(): void { + $args = $this->make_ability()->args(); + + $this->assertArrayNotHasKey( 'input_schema', $args ); + $this->assertArrayNotHasKey( 'output_schema', $args ); + } + + public function test_args_omits_meta_by_default(): void { + $this->assertArrayNotHasKey( 'meta', $this->make_ability()->args() ); + } + + public function test_meta_override_propagates(): void { + $args = $this->make_ability( meta: [ 'show_in_rest' => true ] )->args(); + + $this->assertSame( [ 'show_in_rest' => true ], $args['meta'] ); + } + + public function test_default_permission_denies_anonymous(): void { + wp_set_current_user( 0 ); + + $this->assertFalse( $this->make_ability()->args()['permission_callback']() ); + } + + public function test_default_permission_denies_subscriber(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + + $this->assertFalse( $this->make_ability()->args()['permission_callback']() ); + } + + public function test_default_permission_allows_administrator(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->assertTrue( $this->make_ability()->args()['permission_callback']() ); + } + + public function test_permission_override_propagates(): void { + $ability = $this->make_ability( + permission: static fn ( mixed $input = null ): bool => current_user_can( 'edit_posts' ) + ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + $this->assertFalse( $ability->args()['permission_callback']() ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'contributor' ] ) ); + $this->assertTrue( $ability->args()['permission_callback']() ); + } + + public function test_permission_callback_receives_input(): void { + $received = new \ArrayObject(); + $ability = $this->make_ability( + permission: static function ( mixed $input = null ) use ( $received ): bool { + $received->append( $input ); + return true; + } + ); + + $ability->args()['permission_callback']( [ 'key' => 'value' ] ); + + $this->assertSame( [ [ 'key' => 'value' ] ], $received->getArrayCopy() ); + } +} From 7533ec3249d82f903925a784d8d5f6d10b983c66 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 23 Jul 2026 13:26:01 +0530 Subject: [PATCH 2/2] docs: document ability abstracts; sync AI instruction files Abstracts cookbook gains an Abilities section (must-implement list, override seams, shared-category consumer example, the WP <6.9 inertness note); README and docs index updated. The AI-instruction ability lists now name the ability abstracts and wp_register_ability, and pick up AbstractFeature, which was missing from both since it landed. --- .github/instructions/php.instructions.md | 2 +- AGENTS.md | 2 +- README.md | 7 +- ai/framework-php.instructions.md | 4 +- docs/abstracts.md | 96 +++++++++++++++++++++++- docs/index.md | 9 ++- 6 files changed, 105 insertions(+), 15 deletions(-) diff --git a/.github/instructions/php.instructions.md b/.github/instructions/php.instructions.md index 7dea226..a052500 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`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, `AbstractUserRole`, `AbstractFeature`, `AbstractAbility`, `AbstractAbilityRegistrar`. - `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..2e7469f 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 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 APIs used above 6.5 are `wp_register_block_types_from_metadata_collection()` (6.8+), which `AssetLoader::register_block_manifest()` guards with a per-block fallback for 6.5–6.7, and the Abilities API (6.9+), which the ability abstracts reach only through `wp_abilities_api_*` hooks that never fire on older cores. ## Authoritative rules diff --git a/README.md b/README.md index 27ded4e..c9e807f 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,12 @@ PSR-4 autoloading: `rtCamp\WPFramework\` → `inc/`. interfaces - the `Loader` trait (instantiate a list of classes, register their hooks, cache the shared ones) and the `Container` it stores instances in -- **Ten `Abstract*` base classes** — one per WordPress registration chore, so a - consumer writes intent instead of boilerplate: `AbstractModule`, +- **Twelve `Abstract*` base classes** — one per WordPress registration chore, so + a consumer writes intent instead of boilerplate: `AbstractModule`, `AbstractPostType`, `AbstractTaxonomy`, `AbstractBlock`, `AbstractShortcode`, `AbstractRESTController`, `AbstractSettingsPage`, `AbstractAdminPage`, - `AbstractUserRole`, `AbstractFeature` + `AbstractUserRole`, `AbstractFeature`, `AbstractAbility`, + `AbstractAbilityRegistrar` - **Asset & render loaders** — `AssetLoader` (scripts/styles/modules + `*.asset.php` manifests), `ComponentLoader` and `TemplateLoader` (resolve components/templates across the child-theme → parent-theme → package hierarchy) diff --git a/ai/framework-php.instructions.md b/ai/framework-php.instructions.md index bbfd473..3196f03 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` and `Abstract{PostType,Taxonomy,Block,Shortcode,RESTController,SettingsPage,AdminPage,UserRole,Feature,Ability,AbilityRegistrar}`. Flag genuine contract/security violations, not style. Allow any correct implementation. @@ -48,7 +48,7 @@ Flag genuine contract/security violations, not style. Allow any correct implemen 2. 🚩 `Singleton`/`::get_instance()` outside `Main` → `Loader`+`Registrable` (or `Shareable`+`get_shared()` if retrieval is genuinely needed). Never a service locator. 3. 🚩 `Shareable` with no real later-retrieval need → plain `Registrable`. 4. 🚩 WP-hooking class not implementing `Registrable` / not loaded via the `Loader`. -5. 🚩 A class calling `register_post_type`/`register_taxonomy`/`register_rest_route`/`add_menu_page`/`add_shortcode`/`register_block_type` directly instead of extending the matching `Abstract*` (`AbstractPostType`, `AbstractRESTController`, `AbstractAdminPage`, …). Name the abstract to extend. +5. 🚩 A class calling `register_post_type`/`register_taxonomy`/`register_rest_route`/`add_menu_page`/`add_shortcode`/`register_block_type`/`wp_register_ability` directly instead of extending the matching `Abstract*` (`AbstractPostType`, `AbstractRESTController`, `AbstractAdminPage`, …). Name the abstract to extend. 6. 🚩 Missing `strict_types`/types/docblocks; PSR-4 mismatch; `self::` for LSB. 7. 🚩 Missing escape/sanitize/nonce/capability; raw `$wpdb` without `prepare()`; REST without a real `permission_callback`; inline assets; wrong/absent text domain. 8. 🚩 Edit under `vendor/rtcamp/wp-framework` or WordPress core. diff --git a/docs/abstracts.md b/docs/abstracts.md index fef3cc8..290caeb 100644 --- a/docs/abstracts.md +++ b/docs/abstracts.md @@ -1,13 +1,14 @@ # Abstracts — the base-class cookbook -The ten `Abstract*` classes in +The twelve `Abstract*` classes in [`inc/Contracts/Abstracts/`](../inc/Contracts/Abstracts/) are the part of the framework a service author touches most. Each one wraps a single WordPress registration chore so the subclass writes *what* it is, not *how* to register it. -Every abstract here (except `AbstractModule`, which is structural, and -`AbstractFeature`, which gates another service behind a flag) follows the same -shape: +Every abstract here (except `AbstractModule`, which is structural; +`AbstractFeature`, which gates another service behind a flag; and +`AbstractAbility`, which describes an ability that its paired +`AbstractAbilityRegistrar` registers) follows the same shape: - it `implements Registrable`, so the [`Loader`](architecture.md) drives it; - its `register_hooks()` attaches **one** WordPress hook; @@ -33,6 +34,8 @@ familiar yet. | `AbstractAdminPage` | `admin_menu` | `get_slug()`, `get_page_title()`, `get_menu_title()`, `render()` | | `AbstractUserRole` | `admin_init` | `get_slug()`, `get_display_name()`, `get_capabilities()`, `get_version()` | | `AbstractFeature` | — (gates the subclass's own hooks) | `get_slug()`, `get_feature_registry()`, plus the subclass's `register_hooks()` | +| `AbstractAbility` | — (registered by its `AbstractAbilityRegistrar`) | `name()`, `label()`, `description()`, `category()`, `input_schema()`, `output_schema()`, `execute()` | +| `AbstractAbilityRegistrar` | `wp_abilities_api_categories_init` + `wp_abilities_api_init` | `category_slug()`, `category_description()`, `abilities()` | --- @@ -396,6 +399,91 @@ with that description, and its `the_content` filter attaches only while the flag is enabled. See [utilities.md](utilities.md#featureselector) for the registry and its settings page. +## Abilities (WordPress 6.9+) + +### AbstractAbility + +[`AbstractAbility.php`](../inc/Contracts/Abstracts/AbstractAbility.php) — one +WordPress Abilities API ability. The subclass declares *what* the ability is — +name, label, description, category, schemas, and `execute()` — and `args()` +maps those to the argument array `wp_register_ability()` expects. The class is +deliberately **not** `Registrable`: abilities may only be registered inside the +API's own init hook, so the paired registrar (below) owns that timing and an +ability stays a plain describable object. + +**Must implement:** `name()` (the full `"my-plugin/do-thing"` identifier — +lowercase, one slash), `label()`, `description()`, `category()` (the slug of a +category the registrar registers), `input_schema()` / `output_schema()` (JSON +Schema arrays; return `[]` to omit the key), and +`execute( mixed $input ): array|\WP_Error`. + +Overridable seams: + +- `permission( mixed $input = null )` — the permission gate. Defaults to + `current_user_can( 'manage_options' )`: fail-closed, administrators only. +- `meta()` — defaults to `[]`, which keeps the API defaults: not exposed over + REST, no MCP flag. Exposure is always an explicit opt-in. + +Both callbacks in `args()` are closure-wrapped with a defaulted parameter +because core invokes them with **no arguments** when the ability declares no +input schema. + +### AbstractAbilityRegistrar + +[`AbstractAbilityRegistrar.php`](../inc/Contracts/Abstracts/AbstractAbilityRegistrar.php) +— the `Registrable` that registers a group of abilities. Its `register_hooks()` +attaches two actions — `wp_abilities_api_categories_init` (registers the shared +category) and `wp_abilities_api_init` (registers each ability) — the only hooks +those registrations are legal on. Core fires both lazily on first registry +access. On cores older than 6.9 the hooks never fire, so the registrar is +simply inert and the package's 6.5 floor is unchanged. + +**Must implement:** `category_slug()`, `category_description()` (the API +rejects a category without a non-empty description), and `abilities()` — the +`AbstractAbility[]` to register. + +Overridable: `category_label()` (defaults to the title-cased slug) and +`before_register()` (an empty hook that runs once before the abilities +register — ensure storage exists, prime options). Category registration is +idempotent — a registrar skips the call when the slug already exists — so +several registrars can share one category and load in any order. + +The usual shape mirrors `AbstractFeature`: a thin consumer-side base supplies +the shared category slug, one class per ability, one registrar naming them: + +```php +// One shared category for the whole plugin. +abstract class Ability extends AbstractAbility { + protected function category(): string { return 'my-plugin'; } +} + +final class SiteSummary extends Ability { + public function name(): string { return 'my-plugin/site-summary'; } + protected function label(): string { return 'Site Summary'; } + protected function description(): string { + return 'Returns published post counts for the site.'; + } + protected function input_schema(): array { return []; } + protected function output_schema(): array { return [ 'type' => 'object' ]; } + + public function execute( mixed $input ): array|\WP_Error { + return [ 'posts' => (int) wp_count_posts()->publish ]; + } +} + +final class Registrar extends AbstractAbilityRegistrar { + protected function category_slug(): string { return 'my-plugin'; } + protected function category_description(): string { + return 'Read-only insights about the site.'; + } + protected function abilities(): array { return [ new SiteSummary() ]; } +} +``` + +Load `Registrar` like any other `Registrable` (usually from a module's +`get_classes()`); the ability is then retrievable via +`wp_get_ability( 'my-plugin/site-summary' )` and executable by administrators. + --- Next: [loaders.md](loaders.md) for the asset and template machinery, or back to diff --git a/docs/index.md b/docs/index.md index 325ef7b..7c7b939 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,10 +18,11 @@ 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. -2. **A library of base classes.** Ten `Abstract*` classes — most wrap one +2. **A library of base classes.** Twelve `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 - `AbstractFeature` gates one behind a flag. See [abstracts.md](abstracts.md). + page, an ability, …); two are structural: `AbstractModule` groups services + and `AbstractFeature` gates one behind a flag. See + [abstracts.md](abstracts.md). 3. **Asset & render plumbing.** `AssetLoader`, `ComponentLoader`, and `TemplateLoader` — enqueue built assets and resolve component/template files across the child-theme → parent-theme → package hierarchy. See @@ -36,7 +37,7 @@ Two rules define the whole package: |---|---| | [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. | +| [abstracts.md](abstracts.md) | Cookbook for the twelve `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`, `XHProf_Profiler`, and `Container`. | | [ai-review-system.md](ai-review-system.md) | How the AI review instructions are authored here and synced into the skeletons. |