diff --git a/docs/checks.md b/docs/checks.md index f7ffd0e72..4d01edc62 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -36,4 +36,5 @@ | enqueued_styles_scope | performance | Checks whether any stylesheets are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | | enqueued_scripts_scope | performance | Checks whether any scripts are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | | non_blocking_scripts | performance | Checks whether scripts and styles are enqueued using a recommended loading strategy. | [Learn more](https://developer.wordpress.org/plugins/) | +| autoloaded_options | performance | Warns when add_option() or update_option() are called without explicitly setting the $autoload parameter. | [Learn more](https://developer.wordpress.org/reference/functions/add_option/) | | ai_provider | general | Recommends the WordPress AI Client when a plugin integrates directly with a third-party AI provider. | [Learn more](https://developer.wordpress.org/plugins/) | diff --git a/includes/Checker/Checks/Performance/Autoloaded_Options_Check.php b/includes/Checker/Checks/Performance/Autoloaded_Options_Check.php new file mode 100644 index 000000000..b738abd0b --- /dev/null +++ b/includes/Checker/Checks/Performance/Autoloaded_Options_Check.php @@ -0,0 +1,78 @@ + 'php', + 'standard' => 'PluginCheck', + 'sniffs' => 'PluginCheck.CodeAnalysis.AutoLoadedOptions', + ); + } + + /** + * Gets the description for the check. + * + * Every check must have a short description explaining what the check does. + * + * @since 2.1.0 + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Warns when add_option() or update_option() are called without explicitly setting the $autoload parameter.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * Every check must have a URL with further information about the check. + * + * @since 2.1.0 + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/reference/functions/add_option/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index d4d5c548d..b86528fe5 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -104,6 +104,7 @@ private function register_default_checks() { 'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(), 'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), + 'autoloaded_options' => new Checks\Performance\Autoloaded_Options_Check(), 'ai_provider' => new Checks\General\AI_Provider_Check(), ) ); diff --git a/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/AutoLoadedOptionsSniff.php b/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/AutoLoadedOptionsSniff.php new file mode 100644 index 000000000..e6b47a5a2 --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/AutoLoadedOptionsSniff.php @@ -0,0 +1,101 @@ + + */ + protected $autoload_positions = array( + 'add_option' => 4, + 'update_option' => 3, + ); + + /** + * List of functions to examine. + * + * @since 2.1.0 + * + * @var array + */ + protected $target_functions = array( + 'add_option' => true, + 'update_option' => true, + ); + + /** + * Processes this test, when one of its tokens is encountered. + * + * @since 2.1.0 + * + * @param int $stackPtr The position of the current token in the stack. + * @return int|void Integer stack pointer to skip forward or void to continue normal file processing. + */ + public function process_token( $stackPtr ) { + if ( isset( $this->target_functions[ strtolower( $this->tokens[ $stackPtr ]['content'] ) ] ) ) { + $this->exclude = array(); + + return parent::process_token( $stackPtr ); + } + } + + /** + * Process the parameters of a matched function call. + * + * @since 2.1.0 + * + * @param int $stackPtr The position of the current token in the stack. + * @param string $group_name The name of the group which was matched. + * @param string $matched_content The token content (function name) which was matched in lowercase. + * @param array $parameters Array with information about the parameters. + * @return void + */ + public function process_parameters( $stackPtr, $group_name, $matched_content, $parameters ) { + $position = $this->autoload_positions[ $matched_content ]; + $found = PassedParameters::getParameterFromStack( $parameters, $position, 'autoload' ); + + if ( false === $found ) { + $error_code = MessageHelper::stringToErrorcode( $matched_content . '_autoload', true ); + + $this->phpcsFile->addWarning( + 'The $autoload parameter for %s() is not explicitly set; the option will default to autoloading on every page request. Pass an explicit boolean (true or false) to make the performance trade-off intentional.', + $stackPtr, + $error_code . 'Missing', + array( $matched_content ) + ); + } + } +} diff --git a/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AutoLoadedOptionsUnitTest.inc b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AutoLoadedOptionsUnitTest.inc new file mode 100644 index 000000000..8d8ac81b0 --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AutoLoadedOptionsUnitTest.inc @@ -0,0 +1,28 @@ + => + */ + public function getErrorList() { + return array(); + } + + /** + * Returns the lines where warnings should occur. + * + * @return array => + */ + public function getWarningList() { + return array( + 4 => 1, + 7 => 1, + 10 => 1, + 13 => 1, + 16 => 1, + ); + } + + /** + * Returns the fully qualified class name (FQCN) of the sniff. + * + * @return string The fully qualified class name of the sniff. + */ + protected function get_sniff_fqcn() { + return AutoLoadedOptionsSniff::class; + } + + /** + * Sets the parameters for the sniff. + * + * @throws \RuntimeException If unable to set the ruleset parameters required for the test. + * + * @param Sniff $sniff The sniff being tested. + */ + public function set_sniff_parameters( Sniff $sniff ) { + } +} diff --git a/phpcs-sniffs/PluginCheck/ruleset.xml b/phpcs-sniffs/PluginCheck/ruleset.xml index 3c5b50d2d..c19f5fa80 100644 --- a/phpcs-sniffs/PluginCheck/ruleset.xml +++ b/phpcs-sniffs/PluginCheck/ruleset.xml @@ -4,6 +4,7 @@ Plugin Check Sniffs + diff --git a/tests/phpunit/testdata/plugins/test-plugin-autoloaded-options-check-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-autoloaded-options-check-with-errors/load.php new file mode 100644 index 000000000..119270473 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-autoloaded-options-check-with-errors/load.php @@ -0,0 +1,28 @@ +run( $check_result ); + + $warnings = $check_result->get_warnings(); + + $this->assertNotEmpty( $warnings ); + $this->assertArrayHasKey( 'load.php', $warnings ); + + // Both add_option calls without $autoload must produce warnings. + $this->assertSame( + 'PluginCheck.CodeAnalysis.AutoLoadedOptions.add_option_autoloadMissing', + $warnings['load.php'][19][1][0]['code'] + ); + $this->assertSame( + 'PluginCheck.CodeAnalysis.AutoLoadedOptions.add_option_autoloadMissing', + $warnings['load.php'][22][1][0]['code'] + ); + + // Both update_option calls without $autoload must produce warnings. + $this->assertSame( + 'PluginCheck.CodeAnalysis.AutoLoadedOptions.update_option_autoloadMissing', + $warnings['load.php'][25][1][0]['code'] + ); + $this->assertSame( + 'PluginCheck.CodeAnalysis.AutoLoadedOptions.update_option_autoloadMissing', + $warnings['load.php'][28][1][0]['code'] + ); + } + + public function test_run_without_errors() { + $check = new Autoloaded_Options_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-autoloaded-options-check-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $this->assertEmpty( $check_result->get_errors() ); + $this->assertEmpty( $check_result->get_warnings() ); + } +}