Add 100+ automated unit tests from .expect file specifications Add session system test Add rsx:constants:regenerate command test Add rsx:logrotate command test Add rsx:clean command test Add rsx:manifest:stats command test Add model enum system test Add model mass assignment prevention test Add rsx:check command test Add migrate:status command test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
346 lines
17 KiB
PHP
346 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\RSpade\CodeQuality\Rules\Models;
|
|
|
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
|
use App\RSpade\Core\Manifest\Manifest;
|
|
|
|
class ModelEnums_CodeQualityRule extends CodeQualityRule_Abstract
|
|
{
|
|
public function get_id(): string
|
|
{
|
|
return 'MODEL-ENUMS-01';
|
|
}
|
|
|
|
public function get_name(): string
|
|
{
|
|
return 'Model Enums Property';
|
|
}
|
|
|
|
public function get_description(): string
|
|
{
|
|
return 'Models extending Rsx_Model_Abstract must have a public static $enums property with proper structure';
|
|
}
|
|
|
|
public function get_file_patterns(): array
|
|
{
|
|
return ['*.php'];
|
|
}
|
|
|
|
public function get_default_severity(): string
|
|
{
|
|
return 'medium';
|
|
}
|
|
|
|
public function check(string $file_path, string $contents, array $metadata = []): void
|
|
{
|
|
// Only check PHP files in /rsx/ directory
|
|
if (!str_contains($file_path, '/rsx/')) {
|
|
return;
|
|
}
|
|
|
|
// Get class name from metadata
|
|
$class_name = $metadata['class'] ?? null;
|
|
if (!$class_name) {
|
|
return;
|
|
}
|
|
|
|
// Check if this is a model (extends Rsx_Model_Abstract)
|
|
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Model_Abstract')) {
|
|
return;
|
|
}
|
|
|
|
// Read original file content and remove single-line comments
|
|
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
|
|
$full_path = str_starts_with($file_path, '/') ? $file_path : $base_path . '/' . $file_path;
|
|
$original_contents = file_get_contents($full_path);
|
|
|
|
// Remove single-line comments but keep line structure
|
|
$lines = explode("\n", $original_contents);
|
|
$processed_lines = [];
|
|
foreach ($lines as $line) {
|
|
$trimmed = trim($line);
|
|
if (str_starts_with($trimmed, '//')) {
|
|
$processed_lines[] = ''; // Keep empty line to preserve line numbers
|
|
} else {
|
|
$processed_lines[] = $line;
|
|
}
|
|
}
|
|
$contents = implode("\n", $processed_lines);
|
|
|
|
// Check for public static $enums property
|
|
if (!preg_match('/public\s+static\s+\$enums\s*=\s*(\[.*?\])\s*;/s', $contents, $match)) {
|
|
// Find class definition line
|
|
$class_line = 1;
|
|
foreach ($lines as $i => $line) {
|
|
if (preg_match('/\bclass\s+' . preg_quote($class_name) . '\b/', $line)) {
|
|
$class_line = $i + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->add_violation(
|
|
$file_path,
|
|
$class_line,
|
|
"Model {$class_name} is missing public static \$enums property",
|
|
$lines[$class_line - 1] ?? '',
|
|
"Add: public static \$enums = [];\n\n" .
|
|
"For models with enum fields (fields that reference lookup tables), use:\n" .
|
|
"public static \$enums = [\n" .
|
|
" 'status' => [\n" .
|
|
" 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active'],\n" .
|
|
" 2 => ['constant' => 'STATUS_INACTIVE', 'label' => 'Inactive'],\n" .
|
|
" ]\n" .
|
|
"];\n\n" .
|
|
"Note: Top-level keys are column names. " .
|
|
"Second-level keys must be integers. Third-level arrays must have 'constant' and 'label' fields.",
|
|
'medium'
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
// Check structure of $enums array
|
|
$enums_content = $match[1];
|
|
|
|
// If not empty array, validate structure
|
|
if (trim($enums_content) !== '[]') {
|
|
// Parse the enums array more carefully
|
|
// We need to identify the structure:
|
|
// 'field_name' => [ integer_value => ['constant' => ..., 'label' => ...], ... ]
|
|
|
|
// First, find the top-level keys (any field name allowed, special handling for is_ prefix)
|
|
// We'll look for patterns like 'key' => [ or "key" => [
|
|
$pattern = '/[\'"]([^\'"]+)[\'\"]\s*=>\s*\[/';
|
|
if (preg_match_all($pattern, $enums_content, $field_matches, PREG_OFFSET_CAPTURE)) {
|
|
foreach ($field_matches[1] as $field_match) {
|
|
$field = $field_match[0];
|
|
$offset = $field_match[1];
|
|
|
|
// No naming convention enforcement - any field name is allowed
|
|
// Only requirement: integer keys for values (checked below)
|
|
// Special handling for is_ prefix (boolean fields) is kept
|
|
|
|
// Now check the structure under this field
|
|
// We need to find the content of this particular field's array
|
|
// This is complex with regex, so we'll do a simpler check
|
|
|
|
// Find where this field's array starts in the content
|
|
// Use a more robust approach to extract the field content
|
|
$field_start_pattern = '/[\'"]' . preg_quote($field) . '[\'\"]\s*=>\s*\[/';
|
|
if (preg_match($field_start_pattern, $enums_content, $match, PREG_OFFSET_CAPTURE)) {
|
|
$start_pos = $match[0][1] + strlen($match[0][0]);
|
|
|
|
// Find the matching closing bracket
|
|
$bracket_count = 1;
|
|
$pos = $start_pos;
|
|
$field_content = '';
|
|
while ($bracket_count > 0 && $pos < strlen($enums_content)) {
|
|
$char = $enums_content[$pos];
|
|
if ($char === '[') {
|
|
$bracket_count++;
|
|
} elseif ($char === ']') {
|
|
$bracket_count--;
|
|
if ($bracket_count === 0) {
|
|
break;
|
|
}
|
|
}
|
|
$field_content .= $char;
|
|
$pos++;
|
|
}
|
|
|
|
// Special validation for boolean fields starting with is_
|
|
if (str_starts_with($field, 'is_')) {
|
|
// Extract all integer keys from the field content
|
|
preg_match_all('/(\d+)\s*=>\s*\[/', $field_content, $key_matches);
|
|
$keys = array_map('intval', $key_matches[1]);
|
|
sort($keys);
|
|
|
|
// Boolean fields must have exactly keys 0 and 1
|
|
if ($keys !== [0, 1]) {
|
|
$line_number = 1;
|
|
foreach ($lines as $i => $line) {
|
|
if ((str_contains($line, "'{$field}'") || str_contains($line, "\"{$field}\""))
|
|
&& str_contains($line, '=>')) {
|
|
$line_number = $i + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Boolean enum field '{$field}' must have exactly keys 0 and 1",
|
|
$lines[$line_number - 1] ?? '',
|
|
"Boolean enum fields starting with 'is_' must have exactly two values with keys 0 and 1.\n" .
|
|
"Example:\n" .
|
|
"'{$field}' => [\n" .
|
|
" true => ['label' => 'Yes'],\n" .
|
|
" false => ['label' => 'No']\n" .
|
|
"]\n\n" .
|
|
"Note: PHP converts true/false keys to 1/0, so in the actual array they will be 0 and 1.\n" .
|
|
'Boolean fields do not use constants - just check if the field is truthy.',
|
|
'medium'
|
|
);
|
|
continue; // Skip remaining validations for this field
|
|
}
|
|
|
|
// Check that boolean fields DON'T have 'constant' keys
|
|
$has_constant = str_contains($field_content, "'constant'") || str_contains($field_content, '"constant"');
|
|
if ($has_constant) {
|
|
$line_number = 1;
|
|
foreach ($lines as $i => $line) {
|
|
if (str_contains($line, "'constant'") || str_contains($line, '"constant"')) {
|
|
if (str_contains($lines[$i - 1] ?? '', $field) ||
|
|
str_contains($lines[$i - 2] ?? '', $field) ||
|
|
str_contains($lines[$i - 3] ?? '', $field)) {
|
|
$line_number = $i + 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Boolean enum field '{$field}' must not have 'constant' keys",
|
|
$lines[$line_number - 1] ?? '',
|
|
"Boolean fields starting with 'is_' should not define constants.\n" .
|
|
"Remove the 'constant' keys and use only 'label':\n\n" .
|
|
"'{$field}' => [\n" .
|
|
" 1 => ['label' => 'Yes'],\n" .
|
|
" 0 => ['label' => 'No']\n" .
|
|
"]\n\n" .
|
|
"To check boolean fields in code, simply use:\n" .
|
|
"if (\$model->{$field}) { // truthy check }",
|
|
'medium'
|
|
);
|
|
}
|
|
|
|
// Check that boolean fields have 'label' keys
|
|
$has_label = str_contains($field_content, "'label'") || str_contains($field_content, '"label"');
|
|
if (!$has_label) {
|
|
$line_number = 1;
|
|
foreach ($lines as $i => $line) {
|
|
if ((str_contains($line, "'{$field}'") || str_contains($line, "\"{$field}\""))
|
|
&& str_contains($line, '=>')) {
|
|
$line_number = $i + 2; // Point to content
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Boolean enum field '{$field}' is missing 'label' keys",
|
|
$lines[$line_number - 1] ?? '',
|
|
"Boolean fields must have 'label' for display purposes:\n\n" .
|
|
"'{$field}' => [\n" .
|
|
" 1 => ['label' => 'Yes'],\n" .
|
|
" 0 => ['label' => 'No']\n" .
|
|
']',
|
|
'medium'
|
|
);
|
|
}
|
|
|
|
continue; // Skip remaining validations for boolean fields
|
|
}
|
|
|
|
// Check for integer keys at second level (non-boolean fields only)
|
|
// Should be patterns like: 1 => [...], 2 => [...],
|
|
if (!preg_match('/\d+\s*=>\s*\[/', $field_content)) {
|
|
// Find line number
|
|
$line_number = 1;
|
|
foreach ($lines as $i => $line) {
|
|
if (str_contains($line, "'{$field}'") || str_contains($line, "\"{$field}\"")) {
|
|
$line_number = $i + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Enum field '{$field}' must use integer keys for values",
|
|
$lines[$line_number - 1] ?? '',
|
|
"Use integer keys for enum values. Example:\n" .
|
|
"'{$field}' => [\n" .
|
|
" 1 => ['constant' => 'CONSTANT_NAME', 'label' => 'Display Name'],\n" .
|
|
" 2 => ['constant' => 'ANOTHER_NAME', 'label' => 'Another Display'],\n" .
|
|
']',
|
|
'medium'
|
|
);
|
|
}
|
|
|
|
// Check for 'constant' and 'label' in the value arrays (non-boolean fields only)
|
|
if (!str_starts_with($field, 'is_')) {
|
|
$has_constant = str_contains($field_content, "'constant'") || str_contains($field_content, '"constant"');
|
|
$has_label = str_contains($field_content, "'label'") || str_contains($field_content, '"label"');
|
|
|
|
if (!$has_constant || !$has_label) {
|
|
$line_number = 1;
|
|
foreach ($lines as $i => $line) {
|
|
if (str_contains($line, "'{$field}'") || str_contains($line, "\"{$field}\"")) {
|
|
$line_number = $i + 2; // Point to content, not field name
|
|
break;
|
|
}
|
|
}
|
|
|
|
$missing = [];
|
|
if (!$has_constant) {
|
|
$missing[] = "'constant'";
|
|
}
|
|
if (!$has_label) {
|
|
$missing[] = "'label'";
|
|
}
|
|
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Enum field '{$field}' is missing required fields: " . implode(', ', $missing),
|
|
$lines[$line_number - 1] ?? '',
|
|
"Each enum value must have 'constant' and 'label' fields. Example:\n" .
|
|
"'{$field}' => [\n" .
|
|
" 1 => [\n" .
|
|
" 'constant' => '" . strtoupper(str_replace('_id', '', $field)) . "_EXAMPLE',\n" .
|
|
" 'label' => 'Example Label'\n" .
|
|
" ]\n" .
|
|
"]\n\n" .
|
|
"The 'constant' should be uppercase and unique within this class.",
|
|
'medium'
|
|
);
|
|
}
|
|
|
|
// Check that constants are uppercase (non-boolean fields only)
|
|
if (preg_match_all("/['\"]constant['\"]\\s*=>\\s*['\"]([^'\"]+)['\"]/", $field_content, $constant_matches)) {
|
|
foreach ($constant_matches[1] as $constant) {
|
|
if ($constant !== strtoupper($constant)) {
|
|
$line_number = 1;
|
|
foreach ($lines as $i => $line) {
|
|
if (str_contains($line, $constant)) {
|
|
$line_number = $i + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Enum constant '{$constant}' must be uppercase",
|
|
$lines[$line_number - 1] ?? '',
|
|
"Change constant to '" . strtoupper($constant) . "'. " .
|
|
'Constants should be uppercase and describe the value, typically starting with ' .
|
|
'the field name prefix (e.g., ROLE_ for role_id field).',
|
|
'medium'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} // End of non-boolean field checks
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|