diff --git a/docs/checks.md b/docs/checks.md index 6f7635b9a..1fc05d005 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -6,7 +6,7 @@ | --- | --- | --- | --- | | i18n_usage | general, plugin_repo | Checks for various internationalization best practices. | [Learn more](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) | | code_obfuscation | plugin_repo | Detects the usage of code obfuscation tools. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | -| file_type | plugin_repo | Detects the usage of hidden and compressed files, VCS directories, application files and badly named files. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | +| file_type | plugin_repo | Detects the usage of hidden and compressed files, VCS directories, application files, badly named files, AI development directories (.cursor, .claude, .aider, .continue, .windsurf, .ai, .github), and unexpected markdown files in plugin root. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | plugin_header_fields | plugin_repo | Checks adherence to the Headers requirements. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/) | | late_escaping | security, plugin_repo | Checks that all output is escaped before being sent to the browser. | [Learn more](https://developer.wordpress.org/apis/security/escaping/) | | safe_redirect | security, plugin_repo | Checks that redirects use wp_safe_redirect() instead of wp_redirect() for security. | [Learn more](https://developer.wordpress.org/reference/functions/wp_safe_redirect/) | diff --git a/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php b/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php index f045bbbdb..168ea3e11 100644 --- a/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php @@ -25,15 +25,16 @@ class File_Type_Check extends Abstract_File_Check { use Amend_Check_Result; use Stable_Check; - const TYPE_COMPRESSED = 1; - const TYPE_PHAR = 2; - const TYPE_VCS = 4; - const TYPE_HIDDEN = 8; - const TYPE_APPLICATION = 16; - const TYPE_BADLY_NAMED = 32; - const TYPE_LIBRARY_CORE = 64; - const TYPE_COMPOSER = 128; - const TYPE_ALL = 255; // Same as all of the above with bitwise OR. + const TYPE_COMPRESSED = 1; + const TYPE_PHAR = 2; + const TYPE_VCS = 4; + const TYPE_HIDDEN = 8; + const TYPE_APPLICATION = 16; + const TYPE_BADLY_NAMED = 32; + const TYPE_LIBRARY_CORE = 64; + const TYPE_COMPOSER = 128; + const TYPE_AI_INSTRUCTIONS = 256; + const TYPE_ALL = 511; // Same as all of the above with bitwise OR. /** * Bitwise flags to control check behavior. @@ -106,6 +107,9 @@ protected function check_files( Check_Result $result, array $files ) { if ( $this->flags & self::TYPE_COMPOSER ) { $this->look_for_composer_files( $result, $files ); } + if ( $this->flags & self::TYPE_AI_INSTRUCTIONS ) { + $this->look_for_ai_instructions( $result, $files ); + } } /** @@ -474,6 +478,139 @@ protected function look_for_composer_files( Check_Result $result, array $files ) } } + /** + * Looks for AI instruction files and directories. + * + * @since 1.8.0 + * + * @param Check_Result $result The check result to amend, including the plugin context to check. + * @param array $files List of absolute file paths. + */ + protected function look_for_ai_instructions( Check_Result $result, array $files ) { + $this->check_ai_directories( $result, $files ); + $this->check_github_directory( $result, $files ); + $this->check_unexpected_markdown_files( $result, $files ); + } + + /** + * Checks for AI instruction directories. + * + * @since 1.8.0 + * + * @param Check_Result $result Check result to amend. + * @param array $files List of file paths. + */ + protected function check_ai_directories( Check_Result $result, array $files ) { + $plugin_path = $result->plugin()->path(); + $ai_directories = array( '.cursor', '.claude', '.aider', '.continue', '.windsurf', '.ai' ); + $found_ai_dirs = array(); + + foreach ( $files as $file ) { + $relative_path = str_replace( $plugin_path, '', $file ); + + foreach ( $ai_directories as $ai_dir ) { + if ( strpos( $relative_path, '/' . $ai_dir . '/' ) !== false || strpos( $relative_path, $ai_dir . '/' ) === 0 ) { + $found_ai_dirs[ $ai_dir ] = true; + break; + } + } + } + + foreach ( array_keys( $found_ai_dirs ) as $ai_dir ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: directory name */ + __( 'AI instruction directory "%s" detected. These directories should not be included in production plugins.', 'plugin-check' ), + $ai_dir + ), + 'ai_instruction_directory', + $plugin_path . $ai_dir, + 0, + 0, + '', + 9 + ); + } + } + + /** + * Checks for GitHub workflow directory. + * + * @since 1.8.0 + * + * @param Check_Result $result Check result to amend. + * @param array $files List of file paths. + */ + protected function check_github_directory( Check_Result $result, array $files ) { + $plugin_path = $result->plugin()->path(); + $found_github = false; + + foreach ( $files as $file ) { + $relative_path = str_replace( $plugin_path, '', $file ); + if ( strpos( $relative_path, '/.github/' ) !== false || strpos( $relative_path, '.github/' ) === 0 ) { + $found_github = true; + break; + } + } + + if ( $found_github ) { + $this->add_result_warning_for_file( + $result, + __( 'GitHub workflow directory ".github" detected. This directory should not be included in production plugins.', 'plugin-check' ), + 'github_directory', + $plugin_path . '.github', + 0, + 0, + '', + 9 + ); + } + } + + /** + * Checks for unexpected markdown files. + * + * @since 1.8.0 + * + * @param Check_Result $result Check result to amend. + * @param array $files List of file paths. + */ + protected function check_unexpected_markdown_files( Check_Result $result, array $files ) { + $plugin_path = $result->plugin()->path(); + $allowed_root_md_files = array( 'README.md', 'readme.txt', 'LICENSE', 'LICENSE.md', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md' ); + $root_md_files = array(); + + foreach ( $files as $file ) { + $relative_path = str_replace( $plugin_path, '', $file ); + $relative_path = ltrim( $relative_path, '/' ); + $basename = basename( $file ); + + if ( substr_count( $relative_path, '/' ) === 0 && pathinfo( $file, PATHINFO_EXTENSION ) === 'md' ) { + if ( ! in_array( $basename, $allowed_root_md_files, true ) ) { + $root_md_files[] = $file; + } + } + } + + foreach ( $root_md_files as $file ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: file name */ + __( 'Unexpected markdown file "%s" detected in plugin root. Only specific markdown files are expected in production plugins.', 'plugin-check' ), + basename( $file ) + ), + 'unexpected_markdown_file', + $file, + 0, + 0, + '', + 9 + ); + } + } + /** * Gets the description for the check. * @@ -484,7 +621,7 @@ protected function look_for_composer_files( Check_Result $result, array $files ) * @return string Description. */ public function get_description(): string { - return __( 'Detects the usage of hidden and compressed files, VCS directories, application files, badly named files and Library Core Files.', 'plugin-check' ); + return __( 'Detects the usage of hidden and compressed files, VCS directories, application files, badly named files, Library Core Files, AI development directories, and unexpected markdown files.', 'plugin-check' ); } /** diff --git a/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/.cursor/rules.md b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/.cursor/rules.md new file mode 100644 index 000000000..565d3657c --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/.cursor/rules.md @@ -0,0 +1,3 @@ +# Cursor Rules + +This file contains AI instructions for the Cursor editor. diff --git a/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/.github/workflows/test.yml b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/.github/workflows/test.yml new file mode 100644 index 000000000..bf74e1f59 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/.github/workflows/test.yml @@ -0,0 +1,7 @@ +name: Test +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 diff --git a/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/CONTRIBUTING.md b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/CONTRIBUTING.md new file mode 100644 index 000000000..a6c0baef0 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing Guide + +This is an unexpected markdown file in the plugin root. diff --git a/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/DEVELOPMENT.md b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/DEVELOPMENT.md new file mode 100644 index 000000000..d35b7c4bb --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/DEVELOPMENT.md @@ -0,0 +1,3 @@ +# Development Guide + +This file contains development instructions. diff --git a/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/load.php new file mode 100644 index 000000000..f2799f745 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-ai-instructions-errors/load.php @@ -0,0 +1,8 @@ +assertTrue( isset( $errors['.hidden-test'][0][0][0] ) ); $this->assertSame( 'hidden_files', $errors['.hidden-test'][0][0][0]['code'] ); } + + public function test_run_with_ai_instructions_errors() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ai-instructions-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new File_Type_Check( File_Type_Check::TYPE_AI_INSTRUCTIONS ); + $check->run( $check_result ); + + $problems = $check_result->get_warnings(); + $problem_count = $check_result->get_warning_count(); + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $problems ); + $this->assertGreaterThanOrEqual( 3, $problem_count ); + $this->assertEmpty( $errors ); + + $found_cursor = false; + $found_github = false; + $found_dev = false; + + foreach ( $problems as $file => $messages ) { + if ( strpos( $file, '.cursor' ) !== false ) { + $found_cursor = true; + $this->assertTrue( isset( $messages[0][0][0] ) ); + $this->assertSame( 'ai_instruction_directory', $messages[0][0][0]['code'] ); + } + if ( strpos( $file, '.github' ) !== false ) { + $found_github = true; + $this->assertTrue( isset( $messages[0][0][0] ) ); + $this->assertSame( 'github_directory', $messages[0][0][0]['code'] ); + } + if ( strpos( $file, 'DEVELOPMENT.md' ) !== false ) { + $found_dev = true; + $this->assertTrue( isset( $messages[0][0][0] ) ); + $this->assertSame( 'unexpected_markdown_file', $messages[0][0][0]['code'] ); + } + } + + $this->assertTrue( $found_cursor, 'Expected .cursor directory to be detected' ); + $this->assertTrue( $found_github, 'Expected .github directory to be detected' ); + $this->assertTrue( $found_dev, 'Expected DEVELOPMENT.md to be detected as unexpected' ); + } + + public function test_run_with_ai_instructions_in_local_dev() { + $filter_callback = function () { + return 'local'; + }; + add_filter( 'wp_get_environment_type', $filter_callback ); + + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ai-instructions-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new File_Type_Check( File_Type_Check::TYPE_AI_INSTRUCTIONS ); + $check->run( $check_result ); + + $warnings = $check_result->get_warnings(); + $warning_count = $check_result->get_warning_count(); + $errors = $check_result->get_errors(); + + $this->assertGreaterThanOrEqual( 3, $warning_count ); + $this->assertNotEmpty( $warnings ); + $this->assertEmpty( $errors ); + + remove_filter( 'wp_get_environment_type', $filter_callback ); + } + + public function test_run_without_ai_instructions_errors() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ai-instructions-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new File_Type_Check( File_Type_Check::TYPE_AI_INSTRUCTIONS ); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + $this->assertEmpty( $errors ); + $this->assertEmpty( $warnings ); + } + + public function test_markdown_files_in_subfolders_allowed() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ai-instructions-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new File_Type_Check( File_Type_Check::TYPE_AI_INSTRUCTIONS ); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + $this->assertEmpty( $errors, 'Markdown files in subfolders should not trigger errors' ); + $this->assertEmpty( $warnings, 'Markdown files in subfolders should not trigger warnings' ); + + foreach ( array_merge( $errors, $warnings ) as $file => $messages ) { + $this->assertStringNotContainsString( 'docs/', $file, 'Files in docs/ subfolder should not be flagged' ); + $this->assertStringNotContainsString( 'GUIDE.md', $file, 'GUIDE.md in subfolder should not be flagged' ); + $this->assertStringNotContainsString( 'API.md', $file, 'API.md in subfolder should not be flagged' ); + } + } }