From 5f07d4c95c41f485232c402904fa21f8fb8796c1 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 25 Sep 2026 09:00:18 +0200 Subject: [PATCH] Guard the API surface pp-hosts depends on Progress Planner reaches users as a Composer dependency of pp-hosts, so a change that is harmless here can break there: a renamed hook, a removed service method, a parent class that stops providing what a subclass overrides. Nothing in this repo noticed, because nothing here knows what pp-hosts uses. pp-hosts-contract.json lists that surface -- hooks, parent classes with their overridden methods and used properties, progress_planner()->get_*() services and the methods called on them, constants, dashboard widget IDs and assets. It is generated in pp-hosts, not written here, so it stays honest about what is actually depended on rather than what someone remembered to record. Two ways to run it. The PHPUnit test runs in the normal suite on every PR. The CLI checker runs without WordPress or a database, which is what the pp-hosts integration runner calls with a contract generated from the branch under test. When it fails the answer is usually to keep the old API and deprecate it. If the break is intended, pp-hosts changes in the same release and the contract is regenerated there. Passes against 1.10.1, with three documented known issues carried over from pp-hosts: two hooks it still listens for that PP no longer fires, and one stylesheet that only ever existed on a reverted branch. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 22 + tests/contract/check-pp-hosts-contract.php | 77 +++ .../class-pp-hosts-contract-checker.php | 399 ++++++++++++ tests/contract/pp-hosts-contract.json | 581 ++++++++++++++++++ tests/phpunit/test-pp-hosts-contract.php | 41 ++ 5 files changed, 1120 insertions(+) create mode 100644 CLAUDE.md create mode 100644 tests/contract/check-pp-hosts-contract.php create mode 100644 tests/contract/class-pp-hosts-contract-checker.php create mode 100644 tests/contract/pp-hosts-contract.json create mode 100644 tests/phpunit/test-pp-hosts-contract.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d99a6ae92 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# Progress Planner + +Progress Planner (PP) is being withdrawn from wordpress.org. It reaches users as a Composer dependency of **pp-hosts** (private repo `ProgressPlanner/pp-hosts`, which requires this repo's `pp-hosts` branch). A change here is only done once it works inside pp-hosts. + +## Branches + +- `develop`: development. `main`: stable. `pp-hosts`: what pp-hosts installs (merge `develop` into it to release to pp-hosts). + +## pp-hosts contract + +`tests/contract/pp-hosts-contract.json` lists the PP API surface pp-hosts depends on: hooks it listens to, parent classes it extends (overridden methods, used properties and constants), `progress_planner()->get_*()` services and the methods called on them, constants, dashboard widget IDs and assets. + +- `tests/phpunit/test-pp-hosts-contract.php` runs on every PR (in the normal PHPUnit suite) and fails if PP stops providing any of it. +- The same check runs without WordPress or a database: `php tests/contract/check-pp-hosts-contract.php [contract.json]`. +- **If it fails:** prefer keeping the old API (deprecate it; don't remove it). If the break is intended, change pp-hosts in the same release. Then regenerate the contract in pp-hosts (`php bin/build-pp-contract.php`) and copy it here. +- **Don't edit the JSON by hand.** It's generated from pp-hosts. `known_issues` in it comes from pp-hosts `tests/contract/known-issues.json`. + +## Testing a PP change inside pp-hosts + +From a pp-hosts checkout, run `bin/pp-integration.sh `. It builds pp-hosts with that PP ref and runs these gates: contract, lint, PHPStan and Playwright e2e. Add `--ai` for a Claude impact report and a browser smoke test. See pp-hosts `CLAUDE.md`. + +Run it after merging notable PRs to `develop`, and always before merging `develop` into `pp-hosts`. diff --git a/tests/contract/check-pp-hosts-contract.php b/tests/contract/check-pp-hosts-contract.php new file mode 100644 index 000000000..5a83374d4 --- /dev/null +++ b/tests/contract/check-pp-hosts-contract.php @@ -0,0 +1,77 @@ +check(); +$prpl_known = $prpl_checker->get_known(); + +if ( $prpl_json ) { + echo \json_encode( + [ + 'failures' => (object) $prpl_failures, + 'known' => (object) $prpl_known, + ], + \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES + ) . "\n"; + exit( empty( $prpl_failures ) ? 0 : 1 ); +} + +foreach ( $prpl_known as $prpl_message => $prpl_reason ) { + echo "known issue: {$prpl_message}\n ({$prpl_reason})\n"; +} + +if ( empty( $prpl_failures ) ) { + echo "pp-hosts contract: OK\n"; +} else { + echo "pp-hosts contract: FAILED\n"; + foreach ( $prpl_failures as $prpl_section => $prpl_messages ) { + echo "\n[{$prpl_section}]\n"; + foreach ( $prpl_messages as $prpl_message ) { + echo " - {$prpl_message}\n"; + } + } +} + +exit( empty( $prpl_failures ) ? 0 : 1 ); diff --git a/tests/contract/class-pp-hosts-contract-checker.php b/tests/contract/class-pp-hosts-contract-checker.php new file mode 100644 index 000000000..71932ab21 --- /dev/null +++ b/tests/contract/class-pp-hosts-contract-checker.php @@ -0,0 +1,399 @@ +pp_dir = \rtrim( $pp_dir, '/' ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file. + $contract = \json_decode( (string) \file_get_contents( $contract_file ), true ); + if ( ! \is_array( $contract ) ) { + throw new \RuntimeException( "Could not read contract file: {$contract_file}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- CLI/test output. + } + $this->contract = $contract; + } + + /** + * Failures matching an entry in the contract's known_issues. + * + * @var array Failure message => reason. + */ + private $known = []; + + /** + * Run all checks. + * + * Failures that mention a key from the contract's `known_issues` map are + * not returned; they are available via get_known() instead, so already + * tracked breakage doesn't fail the build but stays visible. + * + * @return array Failures grouped by section; empty sections are omitted. + */ + public function check() { + $failures = [ + 'hooks' => $this->check_hooks(), + 'classes' => $this->check_classes(), + 'services' => $this->check_services(), + 'constants' => $this->check_constants(), + 'meta_boxes' => $this->check_meta_boxes(), + 'assets' => $this->check_assets(), + ]; + + $this->known = []; + $known = $this->contract['known_issues'] ?? []; + foreach ( $failures as $section => $messages ) { + foreach ( $messages as $index => $message ) { + foreach ( $known as $key => $reason ) { + if ( false !== \strpos( $message, (string) $key ) ) { + $this->known[ $message ] = $reason; + unset( $failures[ $section ][ $index ] ); + break; + } + } + } + $failures[ $section ] = \array_values( $failures[ $section ] ); + } + + return \array_filter( $failures ); + } + + /** + * Known issues hit by the last check() run. + * + * @return array Failure message => reason. + */ + public function get_known() { + return $this->known; + } + + /** + * Hooks pp-hosts listens to must still be fired by Progress Planner. + * + * @return string[] + */ + private function check_hooks() { + $failures = []; + foreach ( $this->contract['hooks']['listens'] ?? [] as $hook ) { + $pattern = '/\b(?:do_action|do_action_ref_array|apply_filters|apply_filters_ref_array)\(\s*[\'"]' . \preg_quote( $hook, '/' ) . '[\'"]/'; + if ( ! \preg_match( $pattern, $this->get_source() ) ) { + $failures[] = "Hook '{$hook}' is no longer fired (no do_action/apply_filters found)."; + } + } + return $failures; + } + + /** + * Classes pp-hosts extends must keep the members pp-hosts overrides or uses. + * + * @return string[] + */ + private function check_classes() { + $failures = []; + foreach ( $this->contract['classes'] ?? [] as $consumer => $spec ) { + $parent_name = $spec['extends']; + if ( ! \class_exists( $parent_name ) && ! \interface_exists( $parent_name ) ) { + $failures[] = "{$consumer}: parent class {$parent_name} no longer exists."; + continue; + } + $parent = new \ReflectionClass( $parent_name ); + + if ( $parent->isFinal() ) { + $failures[] = "{$consumer}: parent class {$parent_name} is now final."; + } + + foreach ( $spec['overrides'] ?? [] as $method => $signature ) { + $failures = \array_merge( $failures, $this->check_override( $consumer, $parent, $method, $signature ) ); + } + + foreach ( $spec['calls'] ?? [] as $method ) { + if ( ! $parent->hasMethod( $method ) ) { + $failures[] = "{$consumer}: calls {$parent_name}::{$method}(), which no longer exists."; + } elseif ( $parent->getMethod( $method )->isPrivate() ) { + $failures[] = "{$consumer}: calls {$parent_name}::{$method}(), which is now private."; + } + } + + foreach ( $spec['properties'] ?? [] as $property => $visibility ) { + if ( ! $parent->hasProperty( $property ) ) { + $failures[] = "{$consumer}: relies on {$parent_name}::\${$property}, which no longer exists."; + continue; + } + $parent_property = $parent->getProperty( $property ); + if ( $parent_property->isPrivate() ) { + $failures[] = "{$consumer}: relies on {$parent_name}::\${$property}, which is now private."; + } elseif ( $parent_property->isPublic() && \in_array( $visibility, [ 'protected', 'private' ], true ) ) { + $failures[] = "{$consumer}: redeclares public {$parent_name}::\${$property} as {$visibility} (fatal)."; + } + } + + foreach ( $spec['constants'] ?? [] as $constant ) { + if ( ! $parent->hasConstant( $constant ) ) { + $failures[] = "{$consumer}: relies on {$parent_name}::{$constant}, which no longer exists."; + } + } + + // A new abstract method in the parent makes concrete pp-hosts subclasses fatal. + if ( empty( $spec['abstract'] ) ) { + foreach ( $parent->getMethods( \ReflectionMethod::IS_ABSTRACT ) as $abstract_method ) { + if ( ! \in_array( $abstract_method->getName(), $spec['chain_methods'] ?? [], true ) ) { + $failures[] = "{$consumer}: does not implement new abstract method {$abstract_method->class}::{$abstract_method->getName()}()."; + } + } + } + } + return $failures; + } + + /** + * Check that an overridden method is still compatible with its parent. + * + * @param string $consumer The pp-hosts class. + * @param \ReflectionClass $parent_class The Progress Planner parent class. + * @param string $method The method name. + * @param array $signature The pp-hosts signature: params, has_return_type, static. + * + * @return string[] + */ + private function check_override( $consumer, $parent_class, $method, $signature ) { + $label = "{$consumer}::{$method}() overrides {$parent_class->getName()}::{$method}()"; + + if ( ! $parent_class->hasMethod( $method ) ) { + return [ "{$label}, which no longer exists (the override is now dead code)." ]; + } + + $parent_method = $parent_class->getMethod( $method ); + $failures = []; + + if ( $parent_method->isFinal() ) { + $failures[] = "{$label}, which is now final (fatal)."; + } + if ( $parent_method->isPrivate() ) { + $failures[] = "{$label}, which is now private."; + } + if ( $parent_method->isStatic() !== (bool) ( $signature['static'] ?? false ) ) { + $failures[] = "{$label}, but static-ness differs (fatal)."; + } + if ( $parent_method->getNumberOfParameters() > (int) $signature['params'] ) { + $failures[] = "{$label}, which now takes {$parent_method->getNumberOfParameters()} parameter(s); pp-hosts accepts {$signature['params']} (fatal)."; + } + if ( $parent_method->hasReturnType() && empty( $signature['has_return_type'] ) ) { + $failures[] = "{$label}, which now declares a return type; pp-hosts does not (fatal)."; + } + + return $failures; + } + + /** + * Service getters on progress_planner() and the methods called on them. + * + * @return string[] + */ + private function check_services() { + $failures = []; + $base = new \ReflectionClass( 'Progress_Planner\Base' ); + + if ( ! \preg_match( '/function progress_planner\(\)/', $this->get_source() ) ) { + $failures[] = 'The progress_planner() function is no longer defined.'; + } + + foreach ( $this->contract['services'] ?? [] as $getter => $methods ) { + // Real methods on Base (e.g. the_asset, get_file_version). + if ( $base->hasMethod( $getter ) ) { + continue; + } + + $class_name = self::getter_to_class( $getter ); + if ( null === $class_name ) { + $failures[] = "progress_planner()->{$getter}() no longer resolves to a class."; + continue; + } + + foreach ( $methods as $method ) { + if ( ! \method_exists( $class_name, $method ) ) { + $failures[] = "progress_planner()->{$getter}()->{$method}(): {$class_name}::{$method}() no longer exists."; + } elseif ( ! ( new \ReflectionMethod( $class_name, $method ) )->isPublic() ) { + $failures[] = "progress_planner()->{$getter}()->{$method}(): {$class_name}::{$method}() is no longer public."; + } + } + } + return $failures; + } + + /** + * Resolve a magic getter name to a class, mirroring Base::__call(). + * + * @param string $getter The getter, e.g. get_ui__branding. + * + * @return string|null + */ + public static function getter_to_class( $getter ) { + $name = \substr( $getter, 4 ); + $class_name = \implode( '\\', \explode( '__', $name ) ); + $class_name = 'Progress_Planner\\' . \implode( '_', \array_map( 'ucfirst', \explode( '_', $class_name ) ) ); + + if ( \class_exists( $class_name ) ) { + return ( new \ReflectionClass( $class_name ) )->getName(); + } + + $deprecations = \Progress_Planner\Utils\Deprecations::BASE_METHODS; + if ( isset( $deprecations[ $getter ] ) ) { + return self::getter_to_class( $deprecations[ $getter ][0] ); + } + + return null; + } + + /** + * Constants pp-hosts reads must still be defined, and constants pp-hosts + * defines for Progress Planner must still be read by it. + * + * @return string[] + */ + private function check_constants() { + $failures = []; + foreach ( $this->contract['constants']['reads'] ?? [] as $constant ) { + if ( ! \preg_match( '/define\(\s*[\'"]' . \preg_quote( $constant, '/' ) . '[\'"]/', $this->get_source() ) ) { + $failures[] = "Constant {$constant} is no longer defined."; + } + } + foreach ( $this->contract['constants']['provides'] ?? [] as $constant ) { + if ( false === \strpos( $this->get_source(), $constant ) ) { + $failures[] = "Constant {$constant} (set by pp-hosts) is no longer read by Progress Planner."; + } + } + return $failures; + } + + /** + * Dashboard widgets pp-hosts removes must still exist under the same ID. + * + * @return string[] + */ + private function check_meta_boxes() { + $failures = []; + foreach ( $this->contract['meta_boxes'] ?? [] as $id ) { + if ( false !== \strpos( $this->get_source(), "'{$id}'" ) || $this->is_dashboard_widget( $id ) ) { + continue; + } + $failures[] = "Dashboard widget '{$id}' no longer exists (pp-hosts removes it)."; + } + return $failures; + } + + /** + * Whether a widget ID is registered by Admin\Dashboard_Widget, which builds + * it as "progress_planner_dashboard_widget_{$this->id}". + * + * @param string $id The widget ID, e.g. progress_planner_dashboard_widget_score. + * + * @return bool + */ + private function is_dashboard_widget( $id ) { + $prefix = 'progress_planner_dashboard_widget_'; + if ( 0 !== \strpos( $id, $prefix ) || false === \strpos( $this->get_source(), "\"{$prefix}{\$this->id}\"" ) ) { + return false; + } + + $suffix = \substr( $id, \strlen( $prefix ) ); + $class_name = 'Progress_Planner\\Admin\\Dashboard_Widget_' . \implode( '_', \array_map( 'ucfirst', \explode( '_', $suffix ) ) ); + if ( ! \class_exists( $class_name ) ) { + return false; + } + + $defaults = ( new \ReflectionClass( $class_name ) )->getDefaultProperties(); + return isset( $defaults['id'] ) && $suffix === $defaults['id']; + } + + /** + * Assets pp-hosts enqueues or prints from Progress Planner must still exist. + * + * @return string[] + */ + private function check_assets() { + $failures = []; + foreach ( $this->contract['assets'] ?? [] as $asset ) { + if ( ! \file_exists( $this->pp_dir . '/' . \ltrim( $asset, '/' ) ) ) { + $failures[] = "Asset {$asset} no longer exists."; + } + } + return $failures; + } + + /** + * Get all Progress Planner PHP source (excluding tests and dependencies). + * + * @return string + */ + private function get_source() { + if ( null !== $this->source ) { + return $this->source; + } + + $this->source = ''; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator( $this->pp_dir, \FilesystemIterator::SKIP_DOTS ), + static function ( $file ) { + return ! \in_array( $file->getFilename(), [ 'vendor', 'node_modules', 'tests', '.git' ], true ); + } + ) + ); + foreach ( $iterator as $file ) { + if ( 'php' === $file->getExtension() ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file. + $this->source .= \file_get_contents( $file->getPathname() ) . "\n"; + } + } + + return $this->source; + } +} diff --git a/tests/contract/pp-hosts-contract.json b/tests/contract/pp-hosts-contract.json new file mode 100644 index 000000000..567fda938 --- /dev/null +++ b/tests/contract/pp-hosts-contract.json @@ -0,0 +1,581 @@ +{ + "_comment": "Generated by pp-hosts/bin/build-pp-contract.php. Do not edit by hand. Copy to progress-planner/tests/contract/pp-hosts-contract.json.", + "hooks": { + "listens": [ + "progress_planner_admin_page_after_widgets", + "progress_planner_admin_widgets", + "progress_planner_dashboard_sidebar", + "progress_planner_data_collectors", + "progress_planner_get_badge", + "progress_planner_latest_completed_badge", + "progress_planner_popover_after_badge_groups", + "progress_planner_show_onboarding", + "progress_planner_suggested_task_completed", + "progress_planner_suggested_tasks_in_rest_format", + "progress_planner_suggested_tasks_providers", + "progress_planner_task_activity_category" + ], + "fires": [ + "progress_planner_get_badge", + "progress_planner_guided_tours" + ] + }, + "classes": { + "PP_Hosts\\First_Run\\Badges_Dashboard\\Badges\\Badge_Flying_Start": { + "extends": "Progress_Planner\\Badges\\Badge", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_name": { + "params": 0, + "has_return_type": false, + "static": false + }, + "progress_callback": { + "params": 1, + "has_return_type": false, + "static": false + } + }, + "calls": [ + "get_saved", + "save_progress" + ], + "properties": { + "background": "protected", + "id": "protected" + }, + "constants": [], + "chain_methods": [ + "get_description", + "get_name", + "progress_callback" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Badges\\Badge_Go_To_Publish": { + "extends": "Progress_Planner\\Badges\\Badge", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_name": { + "params": 0, + "has_return_type": false, + "static": false + }, + "progress_callback": { + "params": 1, + "has_return_type": false, + "static": false + } + }, + "calls": [ + "get_saved", + "save_progress" + ], + "properties": { + "background": "protected", + "id": "protected" + }, + "constants": [], + "chain_methods": [ + "get_description", + "get_name", + "progress_callback" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Badges\\Badge_Onboarding": { + "extends": "Progress_Planner\\Badges\\Badge", + "abstract": true, + "overrides": {}, + "calls": [], + "properties": { + "background": "protected" + }, + "constants": [], + "chain_methods": [] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Check_About_Page": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Check_Categories": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Check_Contact_Info": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Check_Generated_Pages": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Check_Generated_Posts": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Check_Homepage": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Check_Reviews": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Guided_Tour": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": true, + "overrides": { + "get_provider_id": { + "params": 0, + "has_return_type": false, + "static": false + }, + "should_add_task": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "is_dismissable": "protected" + }, + "constants": [ + "IS_ONBOARDING_TASK", + "PROVIDER_ID" + ], + "chain_methods": [ + "get_provider_id", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Tasks\\Task_Write_First_Post": { + "extends": "Progress_Planner\\Suggested_Tasks\\Providers\\Tasks", + "abstract": false, + "overrides": { + "get_description": { + "params": 0, + "has_return_type": false, + "static": false + }, + "get_title": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "priority": "protected" + }, + "constants": [ + "PROVIDER_ID" + ], + "chain_methods": [ + "get_description", + "get_provider_id", + "get_title", + "should_add_task" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Widgets\\Widget_Desktop_Tour_Prompt": { + "extends": "Progress_Planner\\Admin\\Widgets\\Widget", + "abstract": false, + "overrides": { + "enqueue_scripts": { + "params": 0, + "has_return_type": false, + "static": false + }, + "enqueue_styles": { + "params": 0, + "has_return_type": false, + "static": false + }, + "render": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "id": "protected", + "width": "protected" + }, + "constants": [], + "chain_methods": [ + "enqueue_scripts", + "enqueue_styles", + "render" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Widgets\\Widget_Flying_Start": { + "extends": "Progress_Planner\\Admin\\Widgets\\Widget", + "abstract": false, + "overrides": { + "enqueue_scripts": { + "params": 0, + "has_return_type": false, + "static": false + }, + "enqueue_styles": { + "params": 0, + "has_return_type": false, + "static": false + }, + "render": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "id": "protected", + "width": "protected" + }, + "constants": [], + "chain_methods": [ + "enqueue_scripts", + "enqueue_styles", + "render" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Widgets\\Widget_Go_To_Publish": { + "extends": "Progress_Planner\\Admin\\Widgets\\Widget", + "abstract": false, + "overrides": { + "enqueue_scripts": { + "params": 0, + "has_return_type": false, + "static": false + }, + "enqueue_styles": { + "params": 0, + "has_return_type": false, + "static": false + }, + "render": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "id": "protected", + "width": "protected" + }, + "constants": [], + "chain_methods": [ + "enqueue_scripts", + "enqueue_styles", + "render" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Widgets\\Widget_Onboarding_Gauge": { + "extends": "Progress_Planner\\Admin\\Widgets\\Widget", + "abstract": false, + "overrides": { + "__construct": { + "params": 1, + "has_return_type": false, + "static": false + }, + "enqueue_scripts": { + "params": 0, + "has_return_type": false, + "static": false + }, + "enqueue_styles": { + "params": 0, + "has_return_type": false, + "static": false + }, + "render": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "force_last_column": "protected", + "id": "protected", + "width": "protected" + }, + "constants": [], + "chain_methods": [ + "__construct", + "enqueue_scripts", + "enqueue_styles", + "get_badge", + "render" + ] + }, + "PP_Hosts\\First_Run\\Badges_Dashboard\\Widgets\\Widget_Onboarding_Info": { + "extends": "Progress_Planner\\Admin\\Widgets\\Widget", + "abstract": false, + "overrides": { + "enqueue_scripts": { + "params": 0, + "has_return_type": false, + "static": false + }, + "enqueue_styles": { + "params": 0, + "has_return_type": false, + "static": false + }, + "render": { + "params": 0, + "has_return_type": false, + "static": false + } + }, + "calls": [], + "properties": { + "id": "protected", + "width": "protected" + }, + "constants": [], + "chain_methods": [ + "enqueue_scripts", + "enqueue_styles", + "render" + ] + } + }, + "services": { + "get_admin__enqueue": [ + "enqueue_script", + "enqueue_style" + ], + "get_file_version": [], + "get_plugin_upgrade_tasks": [ + "add_upgrade_tasks_popover" + ], + "get_settings": [ + "get", + "set" + ], + "get_ui__branding": [ + "enqueue_inline_css", + "get_branding_id", + "get_widget_title" + ], + "the_asset": [] + }, + "constants": { + "reads": [ + "PROGRESS_PLANNER_DIR", + "PROGRESS_PLANNER_URL" + ], + "provides": [ + "PROGRESS_PLANNER_BRANDING_ID" + ] + }, + "meta_boxes": [ + "progress_planner_dashboard_widget_score", + "progress_planner_dashboard_widget_todo" + ], + "assets": [ + "assets/css/page-widgets/badge-streak-content.css", + "assets/css/page-widgets/suggested-tasks.css", + "assets/css/suggested-task.css", + "assets/images/icon_info.svg", + "assets/js/web-components/prpl-badge.js", + "assets/js/web-components/prpl-gauge.js", + "assets/js/widgets/suggested-tasks.js" + ], + "known_issues": { + "assets/css/page-widgets/badge-streak-content.css": "Enqueued by first-run/badges-dashboard/widgets/class-widget-onboarding-gauge.php, but it only ever existed on the reverted split-widgets branch. Enqueue::enqueue_style() silently skips it.", + "progress_planner_dashboard_sidebar": "pp-hosts first-run/guided-tour/class-integration.php hooks this, but PP never fires it: it was only added in an unmerged PP commit (244b8eb9d \"hello guided tour\"). render_dashboard_launcher() never runs from this hook.", + "progress_planner_show_onboarding": "PP removed this filter in f82660b13 (2025-12-26). hosts/shared/class-yourhosting-base.php still filters it to disable onboarding for YourHosting partners, which now does nothing." + } +} diff --git a/tests/phpunit/test-pp-hosts-contract.php b/tests/phpunit/test-pp-hosts-contract.php new file mode 100644 index 000000000..c17e479fb --- /dev/null +++ b/tests/phpunit/test-pp-hosts-contract.php @@ -0,0 +1,41 @@ +check(); + + $message = ''; + foreach ( $failures as $section => $messages ) { + $message .= "\n[{$section}]\n - " . \implode( "\n - ", $messages ); + } + + $this->assertEmpty( $failures, "Progress Planner no longer satisfies the pp-hosts contract:{$message}" ); + } +}