Created
December 18, 2025 20:02
-
-
Save cagrimmett/acb474e6380d416e9e327165be6fd48e to your computer and use it in GitHub Desktop.
wp-cli syntax checker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: PHP Syntax Linter | |
| * Description: WP-CLI command to check PHP syntax across WordPress installations | |
| * Version: 1.0.0 | |
| * Author: Your Organization | |
| * License: GPL v2 or later | |
| * Requires PHP: 7.4 | |
| * WP-CLI: true | |
| */ | |
| // Prevent direct access | |
| if ( ! defined( 'ABSPATH' ) ) { | |
| exit; | |
| } | |
| // Only load if WP-CLI is present | |
| if ( defined( 'WP_CLI' ) && WP_CLI ) { | |
| class WP_PHP_Syntax_Checker { | |
| private $log_file; | |
| private $php_bin; | |
| private $start_time; | |
| private $total_errors = 0; | |
| private $results = []; // Store results for JSON output | |
| /** | |
| * Check PHP syntax across WordPress installation | |
| * | |
| * ## OPTIONS | |
| * | |
| * [--php=<version>] | |
| * : PHP version to use for syntax checking | |
| * --- | |
| * default: 8.4 | |
| * options: | |
| * - 8.1 | |
| * - 8.2 | |
| * - 8.3 | |
| * - 8.4 | |
| * --- | |
| * | |
| * [--batch-size=<number>] | |
| * : Number of files to process per batch (helps with memory) | |
| * --- | |
| * default: 25 | |
| * --- | |
| * | |
| * ## EXAMPLES | |
| * | |
| * wp syntax-check run | |
| * wp syntax-check run --php=8.1 | |
| * wp syntax-check run --php=8.2 --batch-size=15 | |
| * | |
| * @when after_wp_load | |
| */ | |
| public function run( $args, $assoc_args ) { | |
| $php_version = $assoc_args['php'] ?? '8.4'; | |
| $batch_size = intval( $assoc_args['batch-size'] ?? 25 ); | |
| // Validation | |
| $valid_versions = [ '8.1', '8.2', '8.3', '8.4' ]; | |
| if ( ! in_array( $php_version, $valid_versions, true ) ) { | |
| WP_CLI::error( "Invalid PHP version: $php_version. Valid: " . implode( ', ', $valid_versions ) ); | |
| } | |
| $this->php_bin = "/usr/local/php{$php_version}/bin/php"; | |
| if ( ! file_exists( $this->php_bin ) ) { | |
| WP_CLI::error( "PHP binary not found: {$this->php_bin}" ); | |
| } | |
| if ( $batch_size < 5 || $batch_size > 100 ) { | |
| WP_CLI::error( "Batch size must be between 5 and 100" ); | |
| } | |
| // Setup log file in uploads with consistent naming | |
| $upload_dir = wp_upload_dir(); | |
| $domain = $this->get_domain(); | |
| $this->log_file = $upload_dir['basedir'] . "/php-syntax-check_{$domain}_{$php_version}.json"; | |
| $this->start_time = microtime( true ); | |
| // Initialize results structure | |
| $this->results = [ | |
| 'domain' => $domain, | |
| 'php_version' => $php_version, | |
| 'timestamp' => gmdate( 'Y-m-d\TH:i:s\Z' ), | |
| 'batch_size' => $batch_size, | |
| 'directories' => [], | |
| 'summary' => [ | |
| 'total_files' => 0, | |
| 'total_errors' => 0, | |
| 'duration_seconds' => 0 | |
| ], | |
| 'errors' => [], | |
| 'status' => 'running' | |
| ]; | |
| // Write initial JSON to track progress | |
| file_put_contents( $this->log_file, wp_json_encode( $this->results, JSON_PRETTY_PRINT ), LOCK_EX ); | |
| WP_CLI::log( "Starting PHP {$php_version} syntax check" ); | |
| WP_CLI::log( "Results file: {$this->log_file}" ); | |
| // Process wp-content directories separately to manage memory | |
| $directories = [ | |
| 'Themes' => get_theme_root(), | |
| 'Plugins' => WP_PLUGIN_DIR, | |
| 'Must-Use Plugins' => WPMU_PLUGIN_DIR, | |
| 'Uploads' => $upload_dir['basedir'], | |
| ]; | |
| foreach ( $directories as $dir_name => $dir_path ) { | |
| if ( is_dir( $dir_path ) ) { | |
| $this->process_directory( $dir_name, $dir_path, $batch_size ); | |
| // Force garbage collection between directories | |
| if ( function_exists( 'gc_collect_cycles' ) ) { | |
| gc_collect_cycles(); | |
| } | |
| } | |
| } | |
| $this->finish_log(); | |
| if ( $this->total_errors > 0 ) { | |
| WP_CLI::warning( "Found {$this->total_errors} files with syntax errors. Check log: {$this->log_file}" ); | |
| } else { | |
| WP_CLI::success( "No syntax errors found! Log: {$this->log_file}" ); | |
| } | |
| } | |
| /** | |
| * Process a single directory | |
| */ | |
| private function process_directory( $dir_name, $dir_path, $batch_size ) { | |
| WP_CLI::log( "Processing {$dir_name}..." ); | |
| $files = $this->find_php_files( $dir_path ); | |
| $total_files = count( $files ); | |
| // Initialize directory result | |
| $dir_result = [ | |
| 'name' => $dir_name, | |
| 'path' => $dir_path, | |
| 'total_files' => $total_files, | |
| 'processed_files' => 0, | |
| 'error_count' => 0, | |
| 'errors' => [] | |
| ]; | |
| if ( $total_files === 0 ) { | |
| $this->results['directories'][] = $dir_result; | |
| return; | |
| } | |
| // Process in small batches to avoid memory issues | |
| $batches = array_chunk( $files, $batch_size ); | |
| $processed = 0; | |
| $progress = WP_CLI\Utils\make_progress_bar( $dir_name, $total_files ); | |
| foreach ( $batches as $batch ) { | |
| $batch_errors = $this->process_batch( $batch ); | |
| $dir_result['errors'] = array_merge( $dir_result['errors'], $batch_errors ); | |
| $processed += count( $batch ); | |
| $progress->tick( count( $batch ) ); | |
| // Small delay and memory cleanup between batches | |
| usleep( 50000 ); // 0.05 seconds | |
| if ( function_exists( 'gc_collect_cycles' ) ) { | |
| gc_collect_cycles(); | |
| } | |
| } | |
| $progress->finish(); | |
| $dir_result['processed_files'] = $processed; | |
| $dir_result['error_count'] = count( $dir_result['errors'] ); | |
| // Add directory result and update totals | |
| $this->results['directories'][] = $dir_result; | |
| $this->results['summary']['total_files'] += $processed; | |
| $this->results['errors'] = array_merge( $this->results['errors'], $dir_result['errors'] ); | |
| $this->total_errors += $dir_result['error_count']; | |
| } | |
| /** | |
| * Find PHP files in directory | |
| */ | |
| private function find_php_files( $directory ) { | |
| // Exclude common directories that don't need checking | |
| $exclude_patterns = [ | |
| 'node_modules', | |
| 'vendor', | |
| '.git', | |
| 'cache', | |
| 'backups', | |
| 'logs' | |
| ]; | |
| // Add managed plugin exclusions if we're in the plugins directory | |
| if ( strpos( $directory, 'plugins' ) !== false ) { | |
| $managed_plugins = [ 'jetpack', 'akismet', 'classic-editor' ]; | |
| $exclude_patterns = array_merge( $exclude_patterns, $managed_plugins ); | |
| } | |
| $find_parts = [ 'find', '-L', escapeshellarg( $directory ) ]; | |
| // Add exclusion patterns | |
| foreach ( $exclude_patterns as $pattern ) { | |
| $find_parts[] = '-path'; | |
| $find_parts[] = escapeshellarg( "*/{$pattern}/*" ); | |
| $find_parts[] = '-prune'; | |
| $find_parts[] = '-o'; | |
| } | |
| $find_parts[] = '-name'; | |
| $find_parts[] = escapeshellarg( '*.php' ); | |
| $find_parts[] = '-type'; | |
| $find_parts[] = 'f'; | |
| $find_parts[] = '-print0'; | |
| $find_parts[] = '2>/dev/null'; | |
| $find_cmd = implode( ' ', $find_parts ); | |
| $file_list = shell_exec( $find_cmd ); | |
| if ( ! $file_list ) { | |
| return []; | |
| } | |
| return array_filter( explode( "\0", trim( $file_list ) ) ); | |
| } | |
| /** | |
| * Process a batch of files and return only errors | |
| */ | |
| private function process_batch( $files ) { | |
| $errors = []; | |
| foreach ( $files as $filepath ) { | |
| if ( empty( $filepath ) ) { | |
| continue; | |
| } | |
| // Security check | |
| $real_path = realpath( $filepath ); | |
| if ( ! $real_path || ! file_exists( $real_path ) ) { | |
| continue; | |
| } | |
| // Use proc_open for reliable execution | |
| $descriptors = [ | |
| 0 => [ 'pipe', 'r' ], | |
| 1 => [ 'pipe', 'w' ], | |
| 2 => [ 'pipe', 'w' ], | |
| ]; | |
| $process = proc_open( | |
| [ $this->php_bin, '-l', $real_path ], | |
| $descriptors, | |
| $pipes, | |
| null, | |
| [ 'LC_ALL' => 'C' ] | |
| ); | |
| if ( is_resource( $process ) ) { | |
| fclose( $pipes[0] ); | |
| $stdout = stream_get_contents( $pipes[1] ); | |
| $stderr = stream_get_contents( $pipes[2] ); | |
| fclose( $pipes[1] ); | |
| fclose( $pipes[2] ); | |
| $exit_code = proc_close( $process ); | |
| // Only process errors, skip successful files | |
| if ( $exit_code !== 0 ) { | |
| $relative_path = str_replace( ABSPATH, '', $real_path ); | |
| $error_msg = trim( $stderr . $stdout ); | |
| $error_entry = [ | |
| 'file' => $relative_path, | |
| 'error' => $error_msg, | |
| 'exit_code' => $exit_code | |
| ]; | |
| $errors[] = $error_entry; | |
| } | |
| } | |
| } | |
| return $errors; | |
| } | |
| /** | |
| * Write JSON results to log file | |
| */ | |
| private function log( $data ) { | |
| // For JSON output, we'll write the complete results at the end | |
| // This method is kept for compatibility but won't write individual lines | |
| } | |
| /** | |
| * Finish the log with complete JSON output | |
| */ | |
| private function finish_log() { | |
| $end_time = microtime( true ); | |
| $duration = round( $end_time - $this->start_time, 2 ); | |
| // Complete the results with all required fields | |
| $this->results['summary']['total_files'] = $this->results['summary']['total_files'] ?? 0; | |
| $this->results['summary']['total_errors'] = $this->total_errors; | |
| $this->results['summary']['duration_seconds'] = $duration; | |
| $this->results['status'] = 'completed'; | |
| // Ensure all expected fields are present | |
| $this->results['domain'] = $this->results['domain'] ?? $this->get_domain(); | |
| $this->results['php_version'] = $this->results['php_version'] ?? '8.4'; | |
| $this->results['timestamp'] = $this->results['timestamp'] ?? gmdate( 'Y-m-d\TH:i:s\Z' ); | |
| $this->results['batch_size'] = $this->results['batch_size'] ?? 25; | |
| $this->results['directories'] = $this->results['directories'] ?? []; | |
| $this->results['errors'] = $this->results['errors'] ?? []; | |
| // Write complete JSON to log file | |
| $json_output = wp_json_encode( $this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); | |
| if ( $json_output === false ) { | |
| // Fallback if JSON encoding fails | |
| $json_output = json_encode( $this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); | |
| } | |
| file_put_contents( $this->log_file, $json_output, LOCK_EX ); | |
| } | |
| /** | |
| * Get domain for identification | |
| */ | |
| private function get_domain() { | |
| // Try various methods to get domain | |
| if ( defined( 'WP_HOME' ) ) { | |
| $host = parse_url( WP_HOME, PHP_URL_HOST ); | |
| if ( $host ) return $host; | |
| } | |
| if ( isset( $_SERVER['HTTP_HOST'] ) ) { | |
| return $_SERVER['HTTP_HOST']; | |
| } | |
| $siteurl = get_option( 'siteurl' ); | |
| if ( $siteurl ) { | |
| $host = parse_url( $siteurl, PHP_URL_HOST ); | |
| if ( $host ) return $host; | |
| } | |
| // Fallback to hostname, sanitized for filename | |
| $hostname = gethostname(); | |
| return preg_replace( '/[^a-zA-Z0-9.-]/', '_', $hostname ); | |
| } | |
| } | |
| WP_CLI::add_command( 'syntax-check', 'WP_PHP_Syntax_Checker' ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment