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>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class ModelBannedRelations_CodeQualityRule extends CodeQualityRule_Abstract
{
// Banned Laravel relationship methods
protected $banned_relations = [
'hasManyThrough',
'hasOneThrough',
];
public function get_id(): string
{
return 'MODEL-BANNED-01';
}
public function get_name(): string
{
return 'Banned Model Relationships';
}
public function get_description(): string
{
return 'Models must not use hasManyThrough or hasOneThrough relationships';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
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;
}
}
// Check for banned relationship usage
foreach ($processed_lines as $i => $line) {
foreach ($this->banned_relations as $banned) {
if (preg_match('/\$this->' . preg_quote($banned) . '\s*\(/', $line)) {
// Find the method name this is in
$method_name = 'unknown';
for ($j = $i; $j >= 0; $j--) {
if (preg_match('/function\s+(\w+)\s*\(/', $processed_lines[$j], $match)) {
$method_name = $match[1];
break;
}
}
$this->add_violation(
$file_path,
$i + 1,
"Model uses banned relationship '{$banned}' in method '{$method_name}'",
$line,
'Replace with explicit relationship traversal or simpler patterns. ' .
'For example, use $this->relation1->flatMap->relation2 or create a ' .
'method that explicitly queries the related data.',
'critical'
);
}
}
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use Exception;
use Illuminate\Support\Facades\Schema;
use ReflectionClass;
use ReflectionMethod;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class ModelColumnMethodConflict_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MODEL-CONFLICT-01';
}
public function get_name(): string
{
return 'Model Column/Method Name Conflict';
}
public function get_description(): string
{
return 'Database column names must not conflict with model method names';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
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;
}
// Try to load the class to get its properties and methods
if (!class_exists($class_name)) {
return;
}
try {
$reflection = new ReflectionClass($class_name);
// Get the $table property value
$table_prop = $reflection->getProperty('table');
$table_prop->setAccessible(true);
$instance = $reflection->newInstanceWithoutConstructor();
$table_name = $table_prop->getValue($instance);
// Get table columns from database
$columns = Schema::getColumnListing($table_name);
// Get all public methods defined in this class (not inherited)
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
$lines = explode("\n", $contents);
foreach ($methods as $method) {
// Only check methods defined in this class, not inherited
if ($method->class !== $class_name) {
continue;
}
$method_name = $method->getName();
// Check if this method name conflicts with a column name
if (in_array($method_name, $columns)) {
$line_number = $method->getStartLine();
$this->add_violation(
$file_path,
$line_number,
"Method '{$method_name}()' conflicts with database column '{$method_name}' in table '{$table_name}'",
$lines[$line_number - 1] ?? '',
'Rename the method to avoid conflict with the database column name',
'critical'
);
}
}
// Also check enum definitions - they shouldn't reference methods
if ($reflection->hasProperty('enums')) {
$enums_prop = $reflection->getProperty('enums');
$enums_prop->setAccessible(true);
$enums = $enums_prop->getValue();
if (is_array($enums)) {
foreach ($enums as $enum_field => $definitions) {
// Check if this enum field name matches a method
if (method_exists($class_name, $enum_field)) {
// Find where this enum is defined
$line_number = 1;
foreach ($lines as $i => $line) {
if (preg_match('/[\'"]' . preg_quote($enum_field) . '[\'"\s]*=>/', $line)) {
$line_number = $i + 1;
break;
}
}
$this->add_violation(
$file_path,
$line_number,
"Enum field '{$enum_field}' conflicts with method '{$enum_field}()' on the model",
$lines[$line_number - 1] ?? '',
'Either rename the method or remove the enum definition',
'critical'
);
}
}
}
}
} catch (Exception $e) {
// If we can't check the database schema (e.g., during CI or before migrations),
// skip this validation
return;
}
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use Exception;
use Illuminate\Support\Facades\Schema;
use ReflectionClass;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class ModelEnumColumns_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MODEL-ENUM-01';
}
public function get_name(): string
{
return 'Model Enum Column Validation';
}
public function get_description(): string
{
return 'Enum definitions must reference actual database columns';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
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;
}
// Try to load the class to get its properties
if (!class_exists($class_name)) {
return;
}
try {
$reflection = new ReflectionClass($class_name);
// Get the $table property value
$table_prop = $reflection->getProperty('table');
$table_prop->setAccessible(true);
$instance = $reflection->newInstanceWithoutConstructor();
$table_name = $table_prop->getValue($instance);
// Get the $enums property if it exists
if (!$reflection->hasProperty('enums')) {
return;
}
$enums_prop = $reflection->getProperty('enums');
$enums_prop->setAccessible(true);
$enums = $enums_prop->getValue();
if (!is_array($enums)) {
return;
}
// Get table columns from database
$columns = Schema::getColumnListing($table_name);
// Check each enum field
$lines = explode("\n", $contents);
foreach ($enums as $column => $definitions) {
if (!in_array($column, $columns)) {
// Find the line where this enum is defined
$line_number = 1;
foreach ($lines as $i => $line) {
if (preg_match('/[\'"]' . preg_quote($column) . '[\'"\s]*=>/', $line)) {
$line_number = $i + 1;
break;
}
}
$this->add_violation(
$file_path,
$line_number,
"Enum field '{$column}' does not exist as a column in table '{$table_name}'",
$lines[$line_number - 1] ?? '',
"Remove the enum definition for '{$column}' or add the column to the database table",
'high'
);
}
}
} catch (Exception $e) {
// If we can't check the database schema (e.g., during CI or before migrations),
// skip this validation
return;
}
}
}

View File

@@ -0,0 +1,368 @@
<?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 ending in _id that reference lookup tables), use:\n" .
"public static \$enums = [\n" .
" 'role_id' => [\n" .
" 1 => ['constant' => 'ROLE_OWNER', 'label' => 'Owner'],\n" .
" 2 => ['constant' => 'ROLE_ADMIN', 'label' => 'Admin'],\n" .
" ]\n" .
"];\n\n" .
"Note: Top-level keys must match column names ending with '_id'. " .
"Second-level keys are the integer values. 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_id' => [ value_id => ['constant' => ..., 'label' => ...], ... ]
// First, find the top-level keys (should end with _id or start with is_)
// 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];
// Check that top-level field names end with _id or start with is_
if (!str_ends_with($field, '_id') && !str_starts_with($field, 'is_')) {
// Find the line number for this field
$line_number = 1;
$chars_before = substr($enums_content, 0, $offset);
$line_number += substr_count($chars_before, "\n");
// Find actual line in original file
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,
"Enum field '{$field}' must end with '_id' or start with 'is_'",
$lines[$line_number - 1] ?? '',
"Rename enum field to either '{$field}_id' or 'is_{$field}'. " .
"Enum field names must end with '_id' for ID fields or start with 'is_' for boolean fields.",
'medium'
);
}
// 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
}
}
}
}
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class ModelExtends_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MODEL-EXTENDS-01';
}
public function get_name(): string
{
return 'Model Must Extend Rsx_Model_Abstract';
}
public function get_description(): string
{
return 'All models must extend Rsx_Model_Abstract, not Model directly';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Whether this rule is called during manifest scan
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* models must extend the correct base class for the framework's ORM to function.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
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 if available
$class_name = $metadata['class'] ?? null;
if (!$class_name) {
return;
}
// Skip if it's Rsx_Model_Abstract itself or abstract class
if ($class_name === 'Rsx_Model_Abstract' || (isset($metadata['abstract']) && $metadata['abstract'])) {
return;
}
// Check if this class is a subclass of Model
if (!Manifest::php_is_subclass_of($class_name, 'Model')) {
return; // Not a model at all
}
// Now check if it extends Rsx_Model_Abstract
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Model_Abstract')) {
// Get line number where class is defined
$lines = explode("\n", $contents);
$line_number = 1;
foreach ($lines as $i => $line) {
if (preg_match('/\bclass\s+' . preg_quote($class_name) . '\b/', $line)) {
$line_number = $i + 1;
break;
}
}
$error_message = "Code Quality Violation (MODEL-EXTENDS-01) - Incorrect Model Inheritance\n\n";
$error_message .= "Model class '{$class_name}' extends Model directly instead of Rsx_Model_Abstract\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$line_number}\n\n";
$error_message .= "CRITICAL: All models in /rsx/ MUST extend Rsx_Model_Abstract for proper framework functionality.\n\n";
$error_message .= "Resolution:\nChange the class declaration to:\n";
$error_message .= "class {$class_name} extends Rsx_Model_Abstract\n\n";
$error_message .= "This ensures mass assignment protection, proper serialization, and framework integration.";
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
$line_number
);
}
}
}

View File

@@ -0,0 +1,236 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Validates fetch() method implementation in Rsx_Model_Abstract subclasses
*
* Rules:
* 1. fetch() must be static
* 2. fetch() must take exactly one parameter: $id (or int $id)
* 3. fetch() must NOT handle arrays - framework handles array splitting
* 4. Rsx_Model_Abstract's fetch() must only throw an exception
*/
class ModelFetchMethod_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'MODEL-FETCH-01';
}
/**
* Get the rule name
*/
public function get_name(): string
{
return 'Model Fetch Method Validation';
}
/**
* Get the rule description
*/
public function get_description(): string
{
return 'Validates fetch() method implementation in Rsx_Model_Abstract subclasses';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Get default severity
*/
public function get_default_severity(): string
{
return 'high';
}
/**
* Run the rule check
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check PHP files
if (!str_ends_with($file_path, '.php')) {
return;
}
// Check if it extends Rsx_Model_Abstract or is Rsx_Model_Abstract itself
$extends = $metadata['extends'] ?? null;
$class_name = $metadata['class'] ?? null;
if (!$class_name) {
return;
}
$is_model = ($extends === 'Rsx_Model_Abstract' || $class_name === 'Rsx_Model_Abstract');
if (!$is_model) {
return;
}
$is_base_model = ($class_name === 'Rsx_Model_Abstract');
// Check if fetch() method exists
if (!isset($metadata['static_methods']['fetch'])) {
// fetch() is optional - models can choose not to implement it
return;
}
$lines = explode("\n", $contents);
// Find the fetch method in the file
$in_fetch = false;
$fetch_line = 0;
$brace_count = 0;
$fetch_content = [];
$fetch_signature = '';
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_num = $i + 1;
// Look for fetch method
if (!$in_fetch) {
if (preg_match('/\b(public\s+)?static\s+function\s+fetch\s*\(/', $line)) {
$in_fetch = true;
$fetch_line = $line_num;
$fetch_signature = $line;
// Get full signature if it spans multiple lines
$temp_line = $line;
$j = $i;
while (!str_contains($temp_line, ')') && $j < count($lines) - 1) {
$j++;
$temp_line .= ' ' . trim($lines[$j]);
}
$fetch_signature = $temp_line;
}
} else {
// Count braces to find end of method
$fetch_content[] = $line;
$brace_count += substr_count($line, '{') - substr_count($line, '}');
if ($brace_count <= 0 && str_contains($line, '}')) {
$in_fetch = false;
}
}
}
if ($fetch_line === 0) {
// Method exists in metadata but not found in file - shouldn't happen
return;
}
// Rule 1: Check if fetch() is static
if (!preg_match('/\bstatic\s+function\s+fetch/', $fetch_signature)) {
$this->add_violation(
$file_path,
$fetch_line,
"fetch() method must be static",
trim($lines[$fetch_line - 1]),
"Add 'static' keyword to the fetch() method declaration",
'high'
);
}
// Rule 2: Check parameters - must be exactly one: $id or int $id
if (preg_match('/function\s+fetch\s*\((.*?)\)/', $fetch_signature, $matches)) {
$params = trim($matches[1]);
// Remove type hints and default values for analysis
$param_parts = explode(',', $params);
if (count($param_parts) !== 1) {
$this->add_violation(
$file_path,
$fetch_line,
"fetch() must take exactly one parameter: \$id",
trim($lines[$fetch_line - 1]),
"Change fetch() signature to accept only one parameter named \$id",
'high'
);
} else {
// Check that the parameter is $id (with optional int type hint)
$param = trim($param_parts[0]);
if (!preg_match('/^(\??int\s+)?\$id$/', $param)) {
$this->add_violation(
$file_path,
$fetch_line,
"fetch() parameter must be named \$id (optionally typed as int)",
trim($lines[$fetch_line - 1]),
"Rename the parameter to \$id",
'high'
);
}
}
}
// Rule 3: Check for is_array($id) pattern
$fetch_body = implode("\n", $fetch_content);
if (preg_match('/\bis_array\s*\(\s*\$id\s*\)/', $fetch_body)) {
// Find the line number
for ($i = 0; $i < count($fetch_content); $i++) {
if (preg_match('/\bis_array\s*\(\s*\$id\s*\)/', $fetch_content[$i])) {
$array_check_line = $fetch_line + $i + 1;
$this->add_violation(
$file_path,
$array_check_line,
"fetch() must not handle arrays. The framework will split arrays and call fetch() for each ID individually.",
trim($lines[$array_check_line - 1]),
"Remove is_array(\$id) checks and only handle single IDs",
'high'
);
break;
}
}
}
// Rule 4: Special handling for Rsx_Model_Abstract base class
if ($is_base_model) {
// Check that it only throws an exception
if (!preg_match('/throw\s+new\s+\\\\?RuntimeException/', $fetch_body)) {
$this->add_violation(
$file_path,
$fetch_line,
"Rsx_Model_Abstract's fetch() must throw a RuntimeException to indicate it's abstract",
trim($lines[$fetch_line - 1]),
"Replace method body with: throw new \\RuntimeException(...)",
'critical'
);
}
// Check for return statements (not in strings)
// Remove string content to avoid false positives
$fetch_body_no_strings = preg_replace('/(["\'])(?:[^\\\\]|\\\\.)*?\1/', '', $fetch_body);
if (preg_match('/\breturn\b(?!\s*["\'])/', $fetch_body_no_strings)) {
// Find the line number
for ($i = 0; $i < count($fetch_content); $i++) {
$line_no_strings = preg_replace('/(["\'])(?:[^\\\\]|\\\\.)*?\1/', '', $fetch_content[$i]);
if (preg_match('/\breturn\b(?!\s*["\'])/', $line_no_strings)) {
$return_line = $fetch_line + $i + 1;
$this->add_violation(
$file_path,
$return_line,
"Rsx_Model_Abstract's fetch() must not have return statements - it should only throw an exception",
trim($lines[$return_line - 1]),
"Remove the return statement",
'critical'
);
break;
}
}
}
}
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* This rule enforces the #[Relationship] attribute pattern for model relationships.
* It checks that:
* 1. Simple relationships have the #[Relationship] attribute
* 2. Complex relationships (multiple return statements) are flagged for refactoring
* 3. Methods with #[Relationship] are actual Laravel relationships
*/
class ModelRelations_CodeQualityRule extends CodeQualityRule_Abstract
{
// Approved Laravel relationship methods
protected $approved_relations = [
'hasMany',
'hasOne',
'belongsTo',
'morphOne',
'morphMany',
'morphTo',
'belongsToMany',
'morphToMany',
'morphedByMany',
];
public function get_id(): string
{
return 'MODEL-REL-01';
}
public function get_name(): string
{
return 'Model Relationship Attributes';
}
public function get_description(): string
{
return 'Model relationships must have #[Relationship] attributes and follow simple patterns';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
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)
try {
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Model_Abstract')) {
return;
}
} catch (\Exception $e) {
// Class not found in manifest, skip
return;
}
// Read original file content
$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);
// Get lines for line number tracking
$lines = explode("\n", $original_contents);
// Get manifest metadata for this file to check for attributes
$manifest_metadata = [];
try {
$manifest = Manifest::get_full_manifest();
// Convert absolute path to relative if needed
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$manifest_path = $file_path;
if (str_starts_with($manifest_path, $base_path . '/')) {
$manifest_path = substr($manifest_path, strlen($base_path) + 1);
}
if (isset($manifest['data']['files'][$manifest_path])) {
$manifest_metadata = $manifest['data']['files'][$manifest_path];
}
} catch (\Exception $e) {
// Manifest not available, skip attribute checking
}
// Track methods we've analyzed
$relationship_methods = [];
$complex_relationships = [];
// Parse each method to find relationships
foreach ($lines as $i => $line) {
// Look for public non-static function declarations
if (preg_match('/public\s+(?!static\s+)function\s+(\w+)\s*\(/', $line, $func_match)) {
$method_name = $func_match[1];
$method_start_line = $i + 1;
// Skip magic methods and Laravel lifecycle methods
if (str_starts_with($method_name, '__') ||
in_array($method_name, ['boot', 'booted', 'booting'])) {
continue;
}
// Find the method body (from { to })
$brace_count = 0;
$in_method = false;
$method_body = '';
$method_lines = [];
for ($j = $i; $j < count($lines); $j++) {
$current_line = $lines[$j];
// Track opening braces
if (strpos($current_line, '{') !== false) {
$brace_count += substr_count($current_line, '{');
$in_method = true;
}
if ($in_method) {
$method_body .= $current_line . "\n";
$method_lines[] = $j + 1; // Store 1-based line numbers
}
// Track closing braces
if (strpos($current_line, '}') !== false) {
$brace_count -= substr_count($current_line, '}');
if ($brace_count <= 0 && $in_method) {
break;
}
}
}
// Count relationship return statements
$relationship_count = 0;
$relationship_types = [];
foreach ($this->approved_relations as $relation_type) {
$pattern = '/return\s+\$this->' . preg_quote($relation_type) . '\s*\(/';
$matches = preg_match_all($pattern, $method_body);
if ($matches > 0) {
$relationship_count += $matches;
$relationship_types[] = $relation_type;
}
}
// Determine if this is a relationship method
if ($relationship_count > 0) {
if ($relationship_count > 1) {
// Complex relationship with multiple return statements
$complex_relationships[$method_name] = [
'line' => $method_start_line,
'count' => $relationship_count,
'types' => $relationship_types
];
} else {
// Simple relationship
$relationship_methods[$method_name] = [
'line' => $method_start_line,
'type' => $relationship_types[0]
];
}
}
}
}
// Check for complex relationships (violation)
foreach ($complex_relationships as $method_name => $info) {
$this->add_violation(
$file_path,
$info['line'],
"Method '{$method_name}' is a complex relationship with {$info['count']} return statements",
$lines[$info['line'] - 1] ?? '',
"Split into separate methods, each returning a single relationship",
'high'
);
}
// Check simple relationships for #[Relationship] attribute
foreach ($relationship_methods as $method_name => $info) {
$has_attribute = false;
// Check in manifest metadata for the attribute
if (isset($manifest_metadata['public_instance_methods'][$method_name]['attributes']['Relationship'])) {
$has_attribute = true;
}
if (!$has_attribute) {
$this->add_violation(
$file_path,
$info['line'],
"Relationship method '{$method_name}' is missing #[Relationship] attribute",
$lines[$info['line'] - 1] ?? '',
"Add #[Relationship] attribute above the method declaration",
'medium'
);
}
}
// Check for methods with #[Relationship] that aren't actually relationships
if (isset($manifest_metadata['public_instance_methods'])) {
foreach ($manifest_metadata['public_instance_methods'] as $method_name => $method_data) {
// Check if has Relationship attribute
if (isset($method_data['attributes']['Relationship'])) {
// Check if it's actually a relationship
if (!isset($relationship_methods[$method_name]) &&
!isset($complex_relationships[$method_name])) {
// Find the line number for this method
$method_line = 1;
foreach ($lines as $i => $line) {
if (preg_match('/function\s+' . preg_quote($method_name) . '\s*\(/', $line)) {
$method_line = $i + 1;
break;
}
}
$this->add_violation(
$file_path,
$method_line,
"Method '{$method_name}' has #[Relationship] attribute but is not a Laravel relationship",
$lines[$method_line - 1] ?? '',
"Remove #[Relationship] attribute as this method doesn't return a relationship",
'high'
);
}
}
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class ModelTable_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MODEL-TABLE-01';
}
public function get_name(): string
{
return 'Model Table Property';
}
public function get_description(): string
{
return 'Models must have a protected $table property set to a string';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
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;
}
}
$processed_contents = implode("\n", $processed_lines);
// Check for protected $table property
if (!preg_match('/protected\s+\$table\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/', $processed_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 protected \$table property",
$lines[$class_line - 1] ?? '',
"Add: protected \$table = 'table_name';",
'high'
);
} else {
// Check if table name is not empty
$table_name = $match[1];
if (empty($table_name)) {
// Lines already split above
$line_number = 1;
foreach ($lines as $i => $line) {
if (preg_match('/protected\s+\$table/', $line)) {
$line_number = $i + 1;
break;
}
}
$this->add_violation(
$file_path,
$line_number,
"Model {$class_name} has empty \$table property",
$lines[$line_number - 1] ?? '',
'Set $table to the appropriate database table name',
'high'
);
}
}
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Prevents use of Laravel's native enum features in RSpade models
*
* RSpade uses its own enum system via public static $enums property
* instead of Laravel's enum casting system to provide:
* - Magic properties like $model->field_label
* - Static methods like Model::field_enum_select()
* - Auto-generated constants like Model::STATUS_ACTIVE
* - Consistent behavior across the framework
*/
class NoLaravelEnums_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MODEL-LARAVEL-ENUM-01';
}
public function get_name(): string
{
return 'No Laravel Native Enums';
}
public function get_description(): string
{
return 'Models extending Rsx_Model_Abstract must not use Laravel native enum features';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
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
$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);
$lines = explode("\n", $original_contents);
// Check for Laravel enum imports
if (preg_match('/use\s+Illuminate\\\\Database\\\\Eloquent\\\\Casts\\\\AsEnum\s*;/i', $original_contents, $match, PREG_OFFSET_CAPTURE)) {
$line_number = substr_count(substr($original_contents, 0, $match[0][1]), "\n") + 1;
$this->add_violation(
$file_path,
$line_number,
"Laravel's AsEnum cast is not allowed in RSpade models",
$lines[$line_number - 1] ?? '',
"Remove the AsEnum import and use RSpade's enum system instead:\n\n" .
"public static \$enums = [\n" .
" 'status_id' => [\n" .
" 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active'],\n" .
" 2 => ['constant' => 'STATUS_INACTIVE', 'label' => 'Inactive']\n" .
" ]\n" .
"];\n\n" .
'This provides magic properties like $model->status_label and methods like Model::status_enum_select()',
'high'
);
}
// Check for enum class imports (e.g., use App\Enums\StatusEnum)
if (preg_match('/use\s+App\\\\Enums\\\\[A-Za-z0-9_]+\s*;/i', $original_contents, $match, PREG_OFFSET_CAPTURE)) {
$line_number = substr_count(substr($original_contents, 0, $match[0][1]), "\n") + 1;
$this->add_violation(
$file_path,
$line_number,
'Laravel enum classes are not allowed in RSpade models',
$lines[$line_number - 1] ?? '',
"Remove the enum class import and define enums directly in the model using:\n\n" .
"public static \$enums = [\n" .
" 'field_id' => [\n" .
" 1 => ['constant' => 'CONSTANT_NAME', 'label' => 'Display Name']\n" .
" ]\n" .
'];',
'high'
);
}
// Check for $casts property with enum casting
if (preg_match('/protected\s+(?:static\s+)?\$casts\s*=\s*\[[^\]]*(?:AsEnum|Enum)::class[^\]]*\]/s', $original_contents, $match, PREG_OFFSET_CAPTURE)) {
$line_number = substr_count(substr($original_contents, 0, $match[0][1]), "\n") + 1;
$this->add_violation(
$file_path,
$line_number,
'Laravel enum casting is not allowed in RSpade models',
$lines[$line_number - 1] ?? '',
"Remove enum casting from \$casts and use RSpade's \$enums property instead.\n\n" .
"RSpade's enum system provides automatic type casting and additional features " .
"like magic properties and dropdown helpers without using Laravel's casting.",
'high'
);
}
// Check for PHP 8.1 native enum declarations in the same file
if (preg_match('/^\s*enum\s+[A-Za-z0-9_]+\s*(?::\s*(?:string|int))?\s*\{/m', $original_contents, $match, PREG_OFFSET_CAPTURE)) {
$line_number = substr_count(substr($original_contents, 0, $match[0][1]), "\n") + 1;
$this->add_violation(
$file_path,
$line_number,
'PHP native enum declarations are not allowed in RSpade model files',
$lines[$line_number - 1] ?? '',
"Move enum definitions to the model's \$enums property:\n\n" .
"public static \$enums = [\n" .
" 'field_id' => [\n" .
" // Integer keys with constant and label\n" .
" 1 => ['constant' => 'VALUE_ONE', 'label' => 'Value One'],\n" .
" 2 => ['constant' => 'VALUE_TWO', 'label' => 'Value Two']\n" .
" ]\n" .
"];\n\n" .
'Then run: php artisan rsx:constants:regenerate',
'high'
);
}
// Check for BackedEnum or UnitEnum interface implementations
if (preg_match('/implements\s+[^{]*(?:BackedEnum|UnitEnum)/i', $original_contents, $match, PREG_OFFSET_CAPTURE)) {
$line_number = substr_count(substr($original_contents, 0, $match[0][1]), "\n") + 1;
$this->add_violation(
$file_path,
$line_number,
'PHP enum interfaces are not allowed in RSpade models',
$lines[$line_number - 1] ?? '',
'RSpade models should not implement enum interfaces. ' .
'Use the $enums property for enum functionality.',
'high'
);
}
// Check for enum() method calls (Laravel's enum validation)
if (preg_match('/->enum\s*\([^)]*\)/i', $original_contents, $match, PREG_OFFSET_CAPTURE)) {
$line_number = substr_count(substr($original_contents, 0, $match[0][1]), "\n") + 1;
// Check if it's in a validation context
$context_start = max(0, $match[0][1] - 200);
$context = substr($original_contents, $context_start, 400);
if (str_contains($context, 'validate') || str_contains($context, 'rules')) {
$this->add_violation(
$file_path,
$line_number,
"Laravel's enum() validation is not compatible with RSpade enums",
$lines[$line_number - 1] ?? '',
"Use Rule::in() with the enum IDs instead:\n\n" .
"use Illuminate\\Validation\\Rule;\n\n" .
"'field_id' => ['required', Rule::in(Model::field_enum_ids())]\n\n" .
'This validates against the integer keys defined in your $enums property.',
'medium'
);
}
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use Exception;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class RequiredModels_CodeQualityRule extends CodeQualityRule_Abstract
{
// Core models that must exist in the system
protected $required_models = [
'File_Model',
'Ip_Address_Model',
'Session',
'Site_User_Model',
'Site_Model',
'User_Invite_Model',
'User_Verification_Model',
'User_Model',
];
public function get_id(): string
{
return 'MODEL-REQUIRED-01';
}
public function get_name(): string
{
return 'Required Core Models';
}
public function get_description(): string
{
return 'Validates that core framework models exist and extend Rsx_Model_Abstract';
}
public function get_file_patterns(): array
{
// This rule doesn't check individual files
return [];
}
public function get_default_severity(): string
{
return 'critical';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule doesn't check individual files
// It uses check_required_models() method instead
}
/**
* Check that all required models exist and are properly configured
* This is called directly by CodeQualityChecker
*/
public function check_required_models(): void
{
foreach ($this->required_models as $model_name) {
// Check if class exists in manifest
try {
$metadata = Manifest::php_get_metadata_by_class($model_name);
if (empty($metadata)) {
$this->add_violation(
'rsx/models/',
0,
"Required model class '{$model_name}' not found in manifest",
'',
'Create the model file at rsx/models/' . strtolower(str_replace('_', '_', $model_name)) . '.php',
'critical'
);
continue;
}
// Check if it extends Rsx_Model_Abstract
if (!Manifest::php_is_subclass_of($model_name, 'Rsx_Model_Abstract')) {
$file_path = $metadata['file'] ?? 'unknown';
$this->add_violation(
$file_path,
0,
"Required model '{$model_name}' does not extend Rsx_Model_Abstract",
'',
"Make sure {$model_name} extends Rsx_Model_Abstract or a subclass of it",
'critical'
);
}
} catch (Exception $e) {
$this->add_violation(
'rsx/models/',
0,
"Required model class '{$model_name}' not found in manifest",
'',
'Create the model file at rsx/models/' . strtolower(str_replace('_', '_', $model_name)) . '.php',
'critical'
);
}
}
}
}