From 9fb8354cfe803dbb38794d82fcfe8ae57f4a84cf Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 26 May 2026 12:12:53 +0200 Subject: [PATCH 1/2] Fix PHPStan errors and phpunit CVE on main Brings main's static analysis and dependency security checks back to green: - Static Analysis: clear 25 pre-existing PHPStan errors. Ports develop's typed @return on Date::get_periods()/get_range() (which also resolves the Chart modify() errors), takes develop's exact versions of class-page-settings, class-activity-scores, class-chart and class-update-140, converts the WP-core require_once ignores to the @phpstan-ignore-next-line form that suppresses under PHPStan 2.1.x, and adds inline ignores elsewhere. - Security check: bump phpunit/phpunit 9.6.30 -> 9.6.34 in composer.lock to resolve CVE-2026-24765 (unsafe deserialization in PHPT code coverage). --- classes/admin/class-page-settings.php | 162 ++++++------------ .../admin/widgets/class-activity-scores.php | 3 +- classes/class-base.php | 9 +- classes/class-suggested-tasks.php | 1 + classes/suggested-tasks/class-task.php | 6 +- .../data-collector/class-inactive-plugins.php | 3 +- .../providers/class-core-update.php | 3 +- .../providers/class-fewer-tags.php | 3 +- .../providers/class-select-locale.php | 6 +- classes/ui/class-chart.php | 131 ++++++++++---- classes/update/class-update-140.php | 6 +- classes/utils/class-color-customizer.php | 2 +- classes/utils/class-date.php | 18 +- composer.lock | 102 +++++------ 14 files changed, 245 insertions(+), 210 deletions(-) diff --git a/classes/admin/class-page-settings.php b/classes/admin/class-page-settings.php index 7d7d42c8f0..e0976691cb 100644 --- a/classes/admin/class-page-settings.php +++ b/classes/admin/class-page-settings.php @@ -14,42 +14,6 @@ */ class Page_Settings { - /** - * Constructor. - */ - public function __construct() { - // Add the admin menu page. - \add_action( 'admin_menu', [ $this, 'add_admin_menu_page' ] ); - - // Add AJAX hooks to save options. - \add_action( 'wp_ajax_prpl_settings_form', [ $this, 'store_settings_form_options' ] ); - } - - /** - * Add admin-menu page, as a submenu in the progress-planner menu. - * - * @return void - */ - public function add_admin_menu_page() { - \add_submenu_page( - 'progress-planner', - \esc_html__( 'Settings', 'progress-planner' ), - \esc_html__( 'Settings', 'progress-planner' ), - 'manage_options', - 'progress-planner-settings', - [ $this, 'add_admin_page_content' ] - ); - } - - /** - * Add content to the admin page of the free plugin. - * - * @return void - */ - public function add_admin_page_content() { - require_once PROGRESS_PLANNER_DIR . '/views/admin-page-settings.php'; - } - /** * Get an array of settings. * @@ -58,27 +22,28 @@ public function add_admin_page_content() { public function get_settings() { $settings = []; foreach ( \progress_planner()->get_page_types()->get_page_types() as $page_type ) { - if ( ! $this->should_show_setting( $page_type['slug'] ) ) { + $slug = (string) $page_type['slug']; // @phpstan-ignore offsetAccess.invalidOffset + if ( ! $this->should_show_setting( $slug ) ) { continue; } - $settings[ $page_type['slug'] ] = [ - 'id' => $page_type['slug'], + $settings[ $slug ] = [ + 'id' => $slug, 'value' => '_no_page_needed', 'isset' => 'no', - 'title' => $page_type['title'], - 'description' => $page_type['description'] ?? '', + 'title' => $page_type['title'], // @phpstan-ignore offsetAccess.invalidOffset + 'description' => $page_type['description'] ?? '', // @phpstan-ignore offsetAccess.invalidOffset 'type' => 'page-select', - 'page' => $page_type['slug'], + 'page' => $slug, ]; - if ( \progress_planner()->get_page_types()->is_page_needed( $page_type['slug'] ) ) { - $type_pages = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $page_type['slug'] ); + if ( \progress_planner()->get_page_types()->is_page_needed( $slug ) ) { + $type_pages = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $slug ); if ( empty( $type_pages ) ) { - $settings[ $page_type['slug'] ]['value'] = \progress_planner()->get_page_types()->get_default_page_id_by_type( $page_type['slug'] ); + $settings[ $slug ]['value'] = \progress_planner()->get_page_types()->get_default_page_id_by_type( $slug ); } else { - $settings[ $page_type['slug'] ]['value'] = $type_pages[0]->ID; - $settings[ $page_type['slug'] ]['isset'] = 'yes'; + $settings[ $slug ]['value'] = $type_pages[0]->ID; + $settings[ $slug ]['isset'] = 'yes'; // If there is more than one page, we need to check if the page has a parent with the same page-type assigned. if ( 1 < \count( $type_pages ) ) { @@ -89,7 +54,7 @@ public function get_settings() { foreach ( $type_pages as $type_page ) { $parent = \get_post_field( 'post_parent', $type_page->ID ); if ( $parent && \in_array( (int) $parent, $type_pages_ids, true ) ) { - $settings[ $page_type['slug'] ]['value'] = $parent; + $settings[ $slug ]['value'] = $parent; break; } } @@ -123,95 +88,76 @@ public function should_show_setting( $page_type ) { } /** - * Store the settings form options. + * Set the page value. + * + * @param array $pages The pages. * * @return void */ - public function store_settings_form_options() { + public function set_page_values( $pages ) { - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); + if ( empty( $pages ) ) { + return; } - // Use check_ajax_referer instead of check_admin_referer for AJAX handlers. - // check_admin_referer is designed for form submissions, not AJAX requests. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( isset( $_POST['pages'] ) ) { - // Sanitize the pages array at point of reception. - $pages = \map_deep( \wp_unslash( $_POST['pages'] ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - - foreach ( $pages as $type => $page_args ) { - $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : ''; + foreach ( $pages as $type => $page_args ) { + $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : ''; - \progress_planner()->get_page_types()->set_no_page_needed( - $type, - 'not-applicable' === $need_page - ); - - // Remove the post-meta from the existing posts. - $existing_posts = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $type ); - foreach ( $existing_posts as $post ) { - if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) { - continue; - } + \progress_planner()->get_page_types()->set_no_page_needed( + $type, + 'not-applicable' === $need_page + ); - // Get the term-ID for the type. - $term = \get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME ); - if ( ! $term instanceof \WP_Term ) { - continue; - } - - // Remove the assigned terms from the `progress_planner_page_types` taxonomy. - \wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME ); + // Remove the post-meta from the existing posts. + $existing_posts = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $type ); + foreach ( $existing_posts as $post ) { + if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) { + continue; } - // Skip if the ID is not set. - if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) { + // Get the term-ID for the type. + $term = \get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME ); + if ( ! $term instanceof \WP_Term ) { continue; } - if ( 'no' !== $page_args['have_page'] ) { - // Add the term to the `progress_planner_page_types` taxonomy. - \progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type ); - } + // Remove the assigned terms from the `progress_planner_page_types` taxonomy. + \wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME ); } - } - - $this->save_settings(); - $this->save_post_types(); - \do_action( 'progress_planner_settings_form_options_stored' ); + // Skip if the ID is not set. + if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) { + continue; + } - \wp_send_json_success( \esc_html__( 'Options stored successfully', 'progress-planner' ) ); + if ( 'no' !== $page_args['have_page'] ) { + // Add the term to the `progress_planner_page_types` taxonomy. + \progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type ); + } + } } /** - * Save the settings. + * Save the redirect on login setting. + * + * @param bool $redirect_on_login Whether to redirect on login. * * @return void */ - public function save_settings() { - // Nonce is already checked in store_settings_form_options() which calls this method. - $redirect_on_login = isset( $_POST['prpl-redirect-on-login'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing - ? \sanitize_text_field( \wp_unslash( $_POST['prpl-redirect-on-login'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing - : false; - - \update_user_meta( \get_current_user_id(), 'prpl_redirect_on_login', (bool) $redirect_on_login ); + public function save_redirect_on_login( $redirect_on_login = false ) { + \update_user_meta( \get_current_user_id(), 'prpl_redirect_on_login', $redirect_on_login ); } /** * Save the post types. * + * @param array $post_types The post types. + * * @return void */ - public function save_post_types() { - // Nonce is already checked in store_settings_form_options() which calls this method. - $include_post_types = isset( $_POST['prpl-post-types-include'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing - ? \array_map( 'sanitize_text_field', \wp_unslash( $_POST['prpl-post-types-include'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing - // If no post types are selected, use the default post types (post and page can be deregistered). + public function save_post_types( $post_types = [] ) { + $include_post_types = ! empty( $post_types ) + ? $post_types : \array_intersect( [ 'post', 'page' ], \progress_planner()->get_settings()->get_public_post_types() ); \progress_planner()->get_settings()->set( 'include_post_types', $include_post_types ); diff --git a/classes/admin/widgets/class-activity-scores.php b/classes/admin/widgets/class-activity-scores.php index 974c7abba8..8adcde6448 100644 --- a/classes/admin/widgets/class-activity-scores.php +++ b/classes/admin/widgets/class-activity-scores.php @@ -98,7 +98,8 @@ public function get_checklist_results() { $items = $this->get_checklist(); $results = []; foreach ( $items as $item ) { - $results[ $item['label'] ] = $item['callback'](); + $label = (string) $item['label']; // @phpstan-ignore offsetAccess.invalidOffset + $results[ $label ] = $item['callback'](); // @phpstan-ignore offsetAccess.invalidOffset } return $results; } diff --git a/classes/class-base.php b/classes/class-base.php index 2aa14bbd4b..a9e8f6b384 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -86,10 +86,12 @@ class Base { */ public function init() { if ( ! \function_exists( 'current_user_can' ) ) { - require_once ABSPATH . 'wp-includes/capabilities.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/capabilities.php'; } if ( ! \function_exists( 'wp_get_current_user' ) ) { - require_once ABSPATH . 'wp-includes/pluggable.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/pluggable.php'; } if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true ) { @@ -380,7 +382,8 @@ public function get_file_version( $file ) { // Otherwise, use the plugin header. if ( ! \function_exists( 'get_file_data' ) ) { - require_once ABSPATH . 'wp-includes/functions.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/functions.php'; } if ( ! self::$plugin_version ) { diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index c79e308ece..01a6ee7066 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -479,6 +479,7 @@ public function rest_api_tax_query( $args, $request ) { // Handle sorting parameters. if ( isset( $request['filter']['orderby'] ) ) { + // @phpstan-ignore-next-line argument.templateType $args['orderby'] = \sanitize_sql_orderby( $request['filter']['orderby'] ); } if ( isset( $request['filter']['order'] ) ) { diff --git a/classes/suggested-tasks/class-task.php b/classes/suggested-tasks/class-task.php index aadebf1383..3bf174c3ca 100644 --- a/classes/suggested-tasks/class-task.php +++ b/classes/suggested-tasks/class-task.php @@ -183,12 +183,14 @@ public function get_rest_formatted_data( $post_id = null ): array { // Make sure WP_REST_Posts_Controller is loaded. if ( ! \class_exists( 'WP_REST_Posts_Controller' ) ) { - require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php'; } // Make sure WP_REST_Request is loaded. if ( ! \class_exists( 'WP_REST_Request' ) ) { - require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php'; } // Use the appropriate controller for the post type. diff --git a/classes/suggested-tasks/data-collector/class-inactive-plugins.php b/classes/suggested-tasks/data-collector/class-inactive-plugins.php index 2495f48da6..abcb264766 100644 --- a/classes/suggested-tasks/data-collector/class-inactive-plugins.php +++ b/classes/suggested-tasks/data-collector/class-inactive-plugins.php @@ -47,7 +47,8 @@ public function update_inactive_plugins_cache() { */ protected function calculate_data() { if ( ! \function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } // Clear the plugins cache, so get_plugins() returns the latest plugins. diff --git a/classes/suggested-tasks/providers/class-core-update.php b/classes/suggested-tasks/providers/class-core-update.php index 714ace08ea..0a681b4373 100644 --- a/classes/suggested-tasks/providers/class-core-update.php +++ b/classes/suggested-tasks/providers/class-core-update.php @@ -107,7 +107,8 @@ public function add_core_update_link( $update_actions ) { public function should_add_task() { // Without this \wp_get_update_data() might not return correct data for the core updates (depending on the timing). if ( ! \function_exists( 'get_core_updates' ) ) { - require_once ABSPATH . 'wp-admin/includes/update.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/update.php'; } // For wp_get_update_data() to return correct data it needs to be called after the 'admin_init' action (with priority 10). diff --git a/classes/suggested-tasks/providers/class-fewer-tags.php b/classes/suggested-tasks/providers/class-fewer-tags.php index 643e3cb6c6..21a929474c 100644 --- a/classes/suggested-tasks/providers/class-fewer-tags.php +++ b/classes/suggested-tasks/providers/class-fewer-tags.php @@ -154,7 +154,8 @@ public function is_task_completed( $task_id = '' ) { protected function is_plugin_active() { if ( null === $this->is_plugin_active ) { if ( ! \function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $plugins = \get_plugins(); diff --git a/classes/suggested-tasks/providers/class-select-locale.php b/classes/suggested-tasks/providers/class-select-locale.php index 88ac6bfcd7..313d4aa5cf 100644 --- a/classes/suggested-tasks/providers/class-select-locale.php +++ b/classes/suggested-tasks/providers/class-select-locale.php @@ -205,7 +205,8 @@ public function print_popover_instructions() { public function print_popover_form_contents() { if ( ! \function_exists( 'wp_get_available_translations' ) ) { - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; } $languages = \get_available_languages(); @@ -277,7 +278,8 @@ public function handle_interactive_task_specific_submit() { // Handle translation installation. if ( \current_user_can( 'install_languages' ) ) { - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; if ( \wp_can_install_language_pack() ) { $language = \wp_download_language_pack( $language_for_update ); diff --git a/classes/ui/class-chart.php b/classes/ui/class-chart.php index 71f2b3a05e..587bd452ee 100644 --- a/classes/ui/class-chart.php +++ b/classes/ui/class-chart.php @@ -15,8 +15,11 @@ class Chart { /** * Build a chart for the stats. * - * @param array $args The arguments for the chart. - * See `get_chart_data` for the available parameters. + * @param array $args { + * The arguments for the chart. See `get_chart_data` for all available parameters. + * + * @type string $type Chart type (e.g., 'line', 'bar'). + * } * * @return void */ @@ -28,23 +31,49 @@ public function the_chart( $args = [] ) { /** * Get data for the chart. * - * @param array $args The arguments for the chart. - * ['items_callback'] The callback to get items. - * ['filter_results'] The callback to filter the results. Leave empty/null to skip filtering. - * ['dates_params'] The dates parameters for the query. - * ['start_date'] The start date for the chart. - * ['end_date'] The end date for the chart. - * ['frequency'] The frequency for the chart nodes. - * ['format'] The format for the label. + * Normalized charts: + * When $args['normalized'] is true, the chart implements a "decay" algorithm that carries + * forward previous period's activities into the current period with decaying values. + * This creates a rolling momentum effect where past activities continue to contribute + * to current scores, gradually diminishing over time. + * + * Example: If a user published 10 posts in January, the normalized chart for February + * will include both February's new posts plus a decayed value from January's posts. + * This encourages consistent activity by showing how past work continues to have impact. + * + * @param array $args { + * The arguments for the chart. + * + * @type callable $items_callback Callback to fetch items for a date range. + * Signature: function( DateTime $start_date, DateTime $end_date ): array + * @type callable|null $filter_results Optional callback to filter results after fetching. + * Signature: function( array $activities ): array + * @type array $dates_params { + * Date range and frequency parameters. * - * @return array + * @type DateTime $start_date The start date for the chart. + * @type DateTime $end_date The end date for the chart. + * @type string $frequency The frequency for chart nodes (e.g., 'day', 'week', 'month'). + * @type string $format The label format (e.g., 'Y-m-d', 'M j'). + * } + * @type bool $normalized Whether to use normalized scoring with decay from previous periods. + * Default false. + * @type callable $color Callback to determine bar/line color. + * Signature: function(): string (hex color code) + * @type callable $count_callback Callback to calculate score from activities. + * Signature: function( array $activities, DateTime|null $date ): int|float + * @type int|null $max Optional maximum value for chart scaling. + * @type string $type Chart type: 'line' or 'bar'. Default 'line'. + * @type array $return_data Which data fields to return in output. + * Default ['label', 'score', 'color']. + * } + * + * @return array Array of chart data points, each containing requested fields (label, score, color, etc). */ public function get_chart_data( $args = [] ) { $activities = []; - /* - * Set default values for the arguments. - */ + // Set default values for the arguments. $args = \wp_parse_args( $args, [ @@ -61,7 +90,7 @@ public function get_chart_data( $args = [] ) { ] ); - // Get the periods for the chart. + // Get the periods for the chart (e.g., months, weeks, days based on frequency). $periods = \progress_planner()->get_utils__date()->get_periods( $args['dates_params']['start_date'], $args['dates_params']['end_date'], @@ -69,15 +98,25 @@ public function get_chart_data( $args = [] ) { ); /* - * "Normalized" charts decay the score of previous months activities, - * and add them to the current month score. - * This means that for "normalized" charts, we need to get activities - * for the month prior to the first period. + * For "normalized" charts, implement a decay algorithm: + * - Previous period's activities "decay" and carry forward into current period + * - This creates momentum: past productivity continues to boost current scores + * - We need to fetch activities from the month BEFORE the chart starts + * - These previous activities will be added (with decay) to the first period's score + * + * Example: For a chart starting Feb 1, fetch Jan 1-31 activities to contribute + * to February's normalized score. */ $previous_period_activities = []; if ( $args['normalized'] ) { - $previous_month_start = ( clone $periods[0]['start_date'] )->modify( '-1 month' ); - $previous_month_end = ( clone $periods[0]['start_date'] )->modify( '-1 day' ); + /** + * The start date of the first period. + * + * @var \DateTime $first_period_start + */ + $first_period_start = $periods[0]['start_date']; + $previous_month_start = ( clone $first_period_start )->modify( '-1 month' ); + $previous_month_end = ( clone $first_period_start )->modify( '-1 day' ); $previous_period_activities = $args['items_callback']( $previous_month_start, $previous_month_end ); if ( $args['filter_results'] ) { $activities = $args['filter_results']( $activities ); @@ -92,7 +131,8 @@ public function get_chart_data( $args = [] ) { $previous_period_activities = $period_data['previous_period_activities']; $period_data_filtered = []; foreach ( $args['return_data'] as $key ) { - $period_data_filtered[ $key ] = $period_data[ $key ]; + $key_string = (string) $key; // @phpstan-ignore offsetAccess.invalidOffset + $period_data_filtered[ $key_string ] = $period_data[ $key_string ]; // @phpstan-ignore offsetAccess.invalidOffset } $data[] = $period_data_filtered; } @@ -101,30 +141,55 @@ public function get_chart_data( $args = [] ) { } /** - * Get the data for a period. + * Get the data for a single period in the chart. * - * @param array $period The period. - * @param array $args The arguments for the chart. - * @param array $previous_period_activities The activities for the previous month. + * For normalized charts, this implements the decay algorithm: + * 1. Calculate score from current period's activities (normal scoring) + * 2. Add decayed score from previous period's activities (normalized bonus) + * 3. Save current activities to decay into next period * - * @return array + * The decay is handled by the count_callback, which typically reduces scores + * based on how old the activities are relative to the current period. + * + * @param array $period { + * The time period being processed. + * + * @type DateTime $start_date Period start date. + * @type DateTime $end_date Period end date. + * @type string $label Human-readable label for this period. + * } + * @param array $args The chart arguments (see get_chart_data). + * @param array $previous_period_activities Activities from the previous period to apply decay to. + * + * @return array { + * Period data with score and metadata. + * + * @type string $label Period label (e.g., "Jan 2025"). + * @type int|float $score Calculated score for this period. + * @type string $color Color for this data point. + * @type array $previous_period_activities Activities to carry forward to next period. + * } */ public function get_period_data( $period, $args, $previous_period_activities ) { - // Get the activities for the period. + // Get the activities for the current period. $activities = $args['items_callback']( $period['start_date'], $period['end_date'] ); - // Filter the results if a callback is provided. + + // Apply optional filtering callback. if ( $args['filter_results'] ) { $activities = $args['filter_results']( $activities ); } - // Calculate the score for the period. + // Calculate the base score from current period's activities. $period_score = $args['count_callback']( $activities, $period['start_date'] ); - // If this is a "normalized" chart, we need to calculate the score for the previous month activities. + // For normalized charts, apply decay algorithm. if ( $args['normalized'] ) { - // Add the previous month activities to the current month score. + // Add decayed score from previous period's activities to current score. + // The count_callback determines the decay rate based on activity age. $period_score += $args['count_callback']( $previous_period_activities, $period['start_date'] ); - // Update the previous month activities for the next iteration of the loop. + + // Save current activities to decay into the next period. + // This creates a rolling momentum effect across time periods. $previous_period_activities = $activities; } diff --git a/classes/update/class-update-140.php b/classes/update/class-update-140.php index 9a25b2c968..aeb83429c4 100644 --- a/classes/update/class-update-140.php +++ b/classes/update/class-update-140.php @@ -41,10 +41,12 @@ private function rename_tasks_option() { // This is to ensure that we don't lose any tasks, and at the same time we don't have duplicate tasks. $tasks = []; foreach ( $new_tasks as $new_task ) { - $tasks[ isset( $new_task['task_id'] ) ? $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ) ] = $new_task; + $key = isset( $new_task['task_id'] ) ? (string) $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ); // @phpstan-ignore offsetAccess.invalidOffset + $tasks[ $key ] = $new_task; } foreach ( $old_tasks as $old_task ) { - $tasks[ isset( $old_task['task_id'] ) ? $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ) ] = $old_task; + $key = isset( $old_task['task_id'] ) ? (string) $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ); // @phpstan-ignore offsetAccess.invalidOffset + $tasks[ $key ] = $old_task; } // Set the tasks option. diff --git a/classes/utils/class-color-customizer.php b/classes/utils/class-color-customizer.php index 6097b86fba..ee216cd837 100644 --- a/classes/utils/class-color-customizer.php +++ b/classes/utils/class-color-customizer.php @@ -130,7 +130,7 @@ private function save_colors() { $post_value = isset( $_POST[ $key ] ) ? \sanitize_text_field( \wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing $color_value = \sanitize_text_field( \wp_unslash( $post_value ) ); if ( ! empty( $color_value ) ) { - $colors[ $variable ] = $color_value; + $colors[ $variable ] = $color_value; // @phpstan-ignore offsetAccess.invalidOffset } } } diff --git a/classes/utils/class-date.php b/classes/utils/class-date.php index 42f6cba79a..2810fa8401 100644 --- a/classes/utils/class-date.php +++ b/classes/utils/class-date.php @@ -18,10 +18,7 @@ class Date { * @param \DateTime $start_date The start date. * @param \DateTime $end_date The end date. * - * @return array [ - * 'start_date' => \DateTime, - * 'end_date' => \DateTime, - * ]. + * @return array > */ public function get_range( $start_date, $end_date ) { $dates = \iterator_to_array( new \DatePeriod( $start_date, new \DateInterval( 'P1D' ), $end_date ), false ); @@ -38,7 +35,7 @@ public function get_range( $start_date, $end_date ) { * @param \DateTime $end_date The end date. * @param string $frequency The frequency. Can be 'daily', 'weekly', 'monthly'. * - * @return array + * @return array */ public function get_periods( $start_date, $end_date, $frequency ) { $end_date->modify( '+1 day' ); @@ -71,8 +68,15 @@ public function get_periods( $start_date, $end_date, $frequency ) { if ( empty( $date_ranges ) ) { return []; } - if ( $end_date->format( 'z' ) !== \end( $date_ranges )['end_date']->format( 'z' ) ) { - $final_end = clone \end( $date_ranges )['end_date']; + $last_range = \end( $date_ranges ); + /** + * The end date of the last range. + * + * @var \DateTime $last_end_date + */ + $last_end_date = $last_range['end_date']; + if ( $end_date->format( 'z' ) !== $last_end_date->format( 'z' ) ) { + $final_end = clone $last_end_date; $date_ranges[] = $this->get_range( $final_end->modify( '+1 day' ), $end_date ); } diff --git a/composer.lock b/composer.lock index ebc2aed9f9..e95cd186fb 100644 --- a/composer.lock +++ b/composer.lock @@ -1038,16 +1038,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -1090,9 +1090,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -1863,16 +1863,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -1904,9 +1904,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpstan/phpstan", @@ -2282,16 +2282,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.30", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b69489b312503bf8fa6d75a76916919d7d2fa6d4", - "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -2313,7 +2313,7 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.9", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", "sebastian/exporter": "^4.0.8", @@ -2365,7 +2365,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.30" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -2389,7 +2389,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T07:35:08+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "psr/container", @@ -3180,6 +3180,7 @@ "type": "github" } ], + "abandoned": true, "time": "2020-10-26T13:08:54+00:00" }, { @@ -3235,20 +3236,21 @@ "type": "github" } ], + "abandoned": true, "time": "2020-09-28T05:30:19+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.9", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -3301,7 +3303,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -3321,7 +3323,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:51:50+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -4381,16 +4383,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -4403,7 +4405,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4428,7 +4430,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -4439,12 +4441,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/event-dispatcher", @@ -4818,16 +4824,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4877,7 +4883,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4897,7 +4903,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -5153,16 +5159,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -5213,7 +5219,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -5233,7 +5239,7 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php81", @@ -6080,13 +6086,13 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], + "platform": {}, + "platform-dev": {}, "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 5a0358cc4da6f00fe44b94eb9f1cf48b31dee6c7 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 26 May 2026 12:19:15 +0200 Subject: [PATCH 2/2] Fix abstract method fatal in test-class-security.php The anonymous classes extending the abstract Tasks_Interactive did not implement the abstract Tasks::should_add_task() method. phpunit 9.6.30 did not surface this, but 9.6.34 (the CVE-2026-24765 fix) does, causing a fatal when the test class loads. Implement should_add_task() in all 8 anonymous task providers. --- tests/phpunit/test-class-security.php | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/phpunit/test-class-security.php b/tests/phpunit/test-class-security.php index c9073f9d7d..57f2f441ff 100644 --- a/tests/phpunit/test-class-security.php +++ b/tests/phpunit/test-class-security.php @@ -189,6 +189,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Set initial value. @@ -273,6 +282,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test without nonce. @@ -334,6 +352,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); @@ -394,6 +421,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Set up a nested option. @@ -470,6 +506,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test 1: Try to update a non-whitelisted option (should FAIL with fix). @@ -548,6 +593,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test updating a whitelisted option (should SUCCEED). @@ -621,6 +675,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test updating the custom whitelisted option (should SUCCEED). @@ -687,6 +750,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; $critical_options = [