createForNewestSupportedVersion(); } return static::$parser; } public function check(string $file_path, string $contents, array $metadata = []): void { // Skip vendor directories if (str_contains($file_path, '/vendor/')) { return; } // Skip CodeQuality directory if (str_contains($file_path, '/CodeQuality/')) { return; } // Check using both original content and AST $this->check_comment_indicated_sanity_checks($file_path, $contents); $this->check_file_class_existence($file_path, $contents); $this->check_empty_exception_handlers($file_path, $contents); $this->check_failed_operation_returns($file_path, $contents); $this->check_missing_array_keys($file_path, $contents); } /** * Rule PHP-SC-001: Comment-Indicated Sanity Checks */ protected function check_comment_indicated_sanity_checks(string $file_path, string $contents): void { $lines = explode("\n", $contents); foreach ($lines as $line_num => $line) { $line_number = $line_num + 1; // Check if line has exception comment if ($this->has_exception_comment($lines, $line_num)) { continue; } // Look for comments with sanity check indicators if (preg_match('/\/\/.*\b(shouldn\'t happen|should never|must exist|should always|impossible|expected to)\b/i', $line) || preg_match('/\/\*.*\b(shouldn\'t happen|should never|must exist|should always|impossible|expected to)\b.*\*\//i', $line) || preg_match('/#.*\b(shouldn\'t happen|should never|must exist|should always|impossible|expected to)\b/i', $line)) { // Check if the next non-comment line is a return or continue without throwing $next_code_line = $this->get_next_code_line($lines, $line_num); if ($next_code_line && preg_match('/^\s*(return|continue)\s*[;\(]/', $next_code_line)) { $this->add_violation( $file_path, $line_number, "Comment indicates a sanity check but code silently continues/returns", $this->get_code_snippet($lines, $line_num), "Add shouldnt_happen() call: shouldnt_happen(\"Expected condition failed\");", 'critical' ); } } } } /** * Rule PHP-SC-002: File/Class Existence Checks */ protected function check_file_class_existence(string $file_path, string $contents): void { $lines = explode("\n", $contents); foreach ($lines as $line_num => $line) { $line_number = $line_num + 1; // Check if line has exception comment if ($this->has_exception_comment($lines, $line_num)) { continue; } // Check for file_exists followed by return/continue if (preg_match('/if\s*\(\s*!?\s*file_exists\s*\([^)]+\)\s*\)/', $line)) { // Look at the block content $block_content = $this->get_block_content($lines, $line_num); if ($block_content && preg_match('/^\s*(return\s+(null|false)?|continue)\s*;/', $block_content)) { // Check context - if after file operation, it's suspicious $context = $this->get_context($lines, $line_num, 5); if (preg_match('/(file_put_contents|fwrite|copy|rename|move_uploaded_file)/', $context)) { $this->add_violation( $file_path, $line_number, "File existence check with silent return after file operation", $this->get_code_snippet($lines, $line_num), "Use sanity check: shouldnt_happen(\"Required file missing: \$path\");", 'high' ); } } } // Check for class_exists followed by return/continue if (preg_match('/if\s*\(\s*!?\s*class_exists\s*\([^)]+\)\s*\)/', $line)) { $block_content = $this->get_block_content($lines, $line_num); if ($block_content && preg_match('/^\s*(return|continue)\s*;/', $block_content)) { // Check context - if after autoload/require, it's suspicious $context = $this->get_context($lines, $line_num, 5); if (preg_match('/(require|include|autoload|spl_autoload)/', $context)) { $this->add_violation( $file_path, $line_number, "Class existence check with silent return after loading", $this->get_code_snippet($lines, $line_num), "Use sanity check: shouldnt_happen(\"Class should have been loaded\");", 'high' ); } } } } } /** * Rule PHP-SC-003: Empty Exception Handlers */ protected function check_empty_exception_handlers(string $file_path, string $contents): void { try { $ast = $this->get_parser()->parse($contents); $nodeFinder = new NodeFinder; // Find all try-catch blocks $try_catches = $nodeFinder->findInstanceOf($ast, Node\Stmt\TryCatch::class); foreach ($try_catches as $try_catch) { foreach ($try_catch->catches as $catch) { $catch_body = $catch->stmts; // Check if catch block is empty or only logs if (empty($catch_body)) { $this->add_violation( $file_path, $catch->getLine(), "Empty catch block - silently swallowing exceptions", null, "Either handle properly or fail loud: shouldnt_happen(\"Operation failed: \" . \$e->getMessage());", 'critical' ); } elseif (count($catch_body) === 1) { // Check if only logging $stmt = $catch_body[0]; if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Node\Expr\FuncCall && isset($stmt->expr->name->parts[0]) && in_array($stmt->expr->name->parts[0], ['error_log', 'log', 'logger'])) { $this->add_violation( $file_path, $catch->getLine(), "Catch block only logs without re-throwing or handling", null, "Either handle properly or fail loud: shouldnt_happen(\"Operation failed: \" . \$e->getMessage());", 'high' ); } } } } } catch (Error $error) { // Parse error - skip AST checks } } /** * Rule PHP-SC-004: Failed Operation Returns */ protected function check_failed_operation_returns(string $file_path, string $contents): void { $lines = explode("\n", $contents); foreach ($lines as $line_num => $line) { $line_number = $line_num + 1; // Check if line has exception comment if ($this->has_exception_comment($lines, $line_num)) { continue; } // Check for file_get_contents with === false check if (preg_match('/\$\w+\s*=\s*file_get_contents\s*\([^)]+\)/', $line)) { $next_line = $lines[$line_num + 1] ?? ''; if (preg_match('/if\s*\(\s*\$\w+\s*===\s*false\s*\)/', $next_line)) { $block_content = $this->get_block_content($lines, $line_num + 1); if ($block_content && preg_match('/^\s*(return|continue)\s*;/', $block_content)) { $this->add_violation( $file_path, $line_number + 1, "Silent return on file_get_contents failure for required operation", $this->get_code_snippet($lines, $line_num + 1), "Add sanity check: shouldnt_happen(\"Failed to read required file: \$file\");", 'high' ); } } } // Check for json_decode with null check if (preg_match('/\$\w+\s*=\s*json_decode\s*\([^)]+\)/', $line)) { $next_line = $lines[$line_num + 1] ?? ''; if (preg_match('/if\s*\(\s*\$\w+\s*===\s*null/', $next_line)) { $block_content = $this->get_block_content($lines, $line_num + 1); if ($block_content && preg_match('/^\s*(return|continue)\s*;/', $block_content)) { $this->add_violation( $file_path, $line_number + 1, "Silent continue on JSON parse error", $this->get_code_snippet($lines, $line_num + 1), "Add sanity check: shouldnt_happen(\"Failed to parse required JSON\");", 'high' ); } } } } } /** * Rule PHP-SC-005: Missing Array Keys */ protected function check_missing_array_keys(string $file_path, string $contents): void { $lines = explode("\n", $contents); foreach ($lines as $line_num => $line) { $line_number = $line_num + 1; // Check if line has exception comment if ($this->has_exception_comment($lines, $line_num)) { continue; } // Check for isset on config/settings/params arrays if (preg_match('/if\s*\(\s*!?\s*isset\s*\(\s*\$(\w+)\[[\'"](\w+)[\'"]\]\s*\)/', $line, $matches)) { $var_name = $matches[1]; $key_name = $matches[2]; // Check if variable name suggests it's required config if (preg_match('/(config|settings|options|params)/', $var_name)) { $block_content = $this->get_block_content($lines, $line_num); if ($block_content && preg_match('/^\s*return\s+null\s*;/', $block_content)) { // Check context - if comment mentions required $context = $this->get_context($lines, $line_num, 3); if (preg_match('/(required|must|should)/', $context)) { $this->add_violation( $file_path, $line_number, "Missing required array key check with silent return", $this->get_code_snippet($lines, $line_num), "Add sanity check: shouldnt_happen(\"Required config key '{$key_name}' missing\");", 'medium' ); } } } } } } /** * Get the next non-comment, non-empty line */ protected function get_next_code_line(array $lines, int $start_index): ?string { for ($i = $start_index + 1; $i < count($lines); $i++) { $line = trim($lines[$i]); if ($line && !str_starts_with($line, '//') && !str_starts_with($line, '#') && !str_starts_with($line, '/*') && !str_starts_with($line, '*')) { return $line; } } return null; } /** * Get the content of a code block (simplified - just next line for now) */ protected function get_block_content(array $lines, int $if_line_index): ?string { // Simplified implementation - just check next line // A full implementation would parse the block properly if (isset($lines[$if_line_index + 1])) { return trim($lines[$if_line_index + 1]); } return null; } /** * Get context lines around a line */ protected function get_context(array $lines, int $line_index, int $radius): string { $start = max(0, $line_index - $radius); $end = min(count($lines) - 1, $line_index + $radius); $context_lines = []; for ($i = $start; $i <= $end; $i++) { $context_lines[] = $lines[$i]; } return implode("\n", $context_lines); } }