Files
rspade_system/app/RSpade/CodeQuality/Rules/SanityChecks/PhpSanityCheck_CodeQualityRule.php
root f6fac6c4bc Fix bin/publish: copy docs.dist from project root
Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 02:08:33 +00:00

361 lines
14 KiB
PHP
Executable File

<?php
namespace App\RSpade\CodeQuality\Rules\SanityChecks;
use PhpParser\Error;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class PhpSanityCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
protected static $parser = null;
public function get_id(): string
{
return 'PHP-SC-001';
}
public function get_name(): string
{
return 'PHP Sanity Check Patterns';
}
public function get_description(): string
{
return 'Detects patterns where code silently continues when it should fail loudly';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
public function is_enabled(): bool
{
// TEMPORARILY DISABLED FOR TESTING
return false;
// Will re-enable after testing other rules
// return parent::is_enabled();
}
/**
* Get or create the parser instance
*/
protected function get_parser()
{
if (static::$parser === null) {
static::$parser = (new ParserFactory)->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);
}
}