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>
361 lines
14 KiB
PHP
Executable File
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);
|
|
}
|
|
} |