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

325
app/RSpade/CodeQuality/CLAUDE.md Executable file
View File

@@ -0,0 +1,325 @@
# RSpade Code Quality System
## Overview
The Code Quality system is a modular, extensible framework for enforcing coding standards and best practices across the RSpade codebase. It replaces a monolithic 1921-line checker with a clean, maintainable architecture using Manifest-based auto-discovery.
## Architecture
### Core Components
1. **CodeQualityChecker** (`CodeQualityChecker.php`)
- Main orchestrator that discovers and runs all rules
- Auto-discovers rules via RuleDiscovery::discover_rules()
- Handles file scanning, caching, and violation collection
- Performs syntax linting for PHP, JavaScript, and JSON files
2. **CodeQualityRule_Abstract** (`Rules/CodeQualityRule_Abstract.php`)
- Base class for all code quality rules
- Defines the interface: `get_id()`, `get_name()`, `check()`, etc.
- Provides `add_violation()` helper method
- Rules self-register by extending this class
3. **Violation** (`Violation.php`)
- Data class representing a code violation
- Contains: rule_id, file_path, line_number, message, severity, code_snippet, suggestion
- Provides `to_array()` for serialization
### Support Classes
- **ViolationCollector** - Aggregates violations from all rules
- **CacheManager** - Caches sanitized file contents to improve performance
- **FileSanitizer** - Removes comments and strings for accurate code analysis
## Rule Categories
### PHP Rules (`Rules/PHP/`)
1. **NamingConventionRule** (PHP-NAMING-01)
- Enforces underscore_case for methods and variables
- Excludes Laravel framework methods (toArray, firstOrCreate, etc.)
- Severity: Medium
2. **MassAssignmentRule** (PHP-MASS-01)
- Prohibits use of $fillable property
- Ensures $guarded = ['*'] or removal
- Severity: High
3. **PhpFallbackLegacyRule** (PHP-FALLBACK-01)
- Detects "fallback" or "legacy" in comments/function names
- Enforces fail-loud principle
- Severity: Critical
4. **DbTableUsageRule** (PHP-DB-01)
- Prohibits DB::table() usage
- Requires ORM models for database access
- Severity: High
5. **FunctionExistsRule** (PHP-FUNC-01)
- Prohibits function_exists() checks
- Enforces predictable runtime environment
- Severity: High
### Jqhtml Rules (`Rules/Jqhtml/`)
1. **JqhtmlInlineScriptRule** (JQHTML-INLINE-01)
- Prohibits inline <script> and <style> tags in .jqhtml templates
- Enforces component class pattern with Jqhtml_Component
- Requires separate .js and .scss files
- Severity: Critical
- Runs at manifest-time
### JavaScript Rules (`Rules/JavaScript/`)
1. **VarUsageRule** (JS-VAR-01)
- Prohibits 'var' keyword, requires let/const
- Severity: Medium
2. **DefensiveCodingRule** (JS-DEFENSIVE-01)
- Prohibits typeof checks for core classes
- Core classes always exist in runtime
- Severity: High
3. **InstanceMethodsRule** (JS-STATIC-01)
- Enforces static methods in JavaScript classes
- Exceptions allowed with @instance-class comment
- Severity: Medium
4. **JQueryUsageRule** (JS-JQUERY-01)
- Enforces $ over jQuery
- Detects deprecated methods (live, die, bind, etc.)
- Severity: Medium
5. **ThisUsageRule** (JS-THIS-01)
- Detects problematic 'this' usage
- Suggests class reference pattern
- Severity: Medium
6. **DocumentReadyRule** (JS-READY-01)
- Prohibits jQuery ready patterns
- Requires ES6 class with static init()
- Severity: High
7. **JsFallbackLegacyRule** (JS-FALLBACK-01)
- JavaScript version of fallback/legacy detection
- Severity: Critical
### Common Rules (`Rules/Common/`)
1. **FilenameCaseRule** (FILE-CASE-01)
- Enforces lowercase filenames
- Severity: Low
2. **FilenameEnhancedRule** (FILE-NAME-01)
- Validates controller/model naming conventions
- Checks file-class name consistency
- Severity: Medium
3. **RootFilesRule** (FILE-ROOT-01)
- Restricts files in project root
- Maintains clean project structure
- Severity: Medium
4. **RsxTestFilesRule** (FILE-RSX-01)
- Prevents test files directly in rsx/
- Enforces proper test organization
- Severity: Medium
5. **RouteExistsRule** (ROUTE-EXISTS-01)
- Validates Rsx::Route() calls reference existing routes
- Checks controller/method combinations exist in manifest
- Suggests placeholder URLs for unimplemented routes
- Severity: High
### Sanity Check Rules (`Rules/SanityChecks/`)
1. **PhpSanityCheckRule** (PHP-SC-001)
- Complex pattern detection (currently disabled)
- Detects suspicious code patterns
- Severity: Critical
## Configuration
### Config File (`config/rsx.php`)
```php
'code_quality' => [
'enabled' => env('CODE_QUALITY_ENABLED', true),
'cache_enabled' => true,
'parallel_processing' => false,
'excluded_directories' => [
'vendor',
'node_modules',
'storage',
'bootstrap/cache',
'CodeQuality', // Exclude checker itself
],
'rsx_test_whitelist' => [
// Files allowed in rsx/ directory
'main.php',
'routes.php',
],
],
```
### Disabling Rules
Rules can be disabled by adding them to the disabled list:
```php
'disabled_rules' => [
'PHP-SC-001', // Temporarily disabled
],
```
## Usage
### Command Line
```bash
# Run all checks
php artisan rsx:check
# Check specific directory
php artisan rsx:check rsx/
# Check specific file
php artisan rsx:check app/Models/User.php
```
### Exception Comments
Add exception comments to bypass specific violations:
```php
// @RULE-ID-EXCEPTION (e.g., @PHP-NAMING-01-EXCEPTION)
// Code that would normally violate rules
```
## Development
### Creating New Rules
1. Create a new class extending `CodeQualityRule_Abstract`
2. Place in appropriate Rules subdirectory
3. Implement required methods:
- `get_id()` - Unique rule identifier
- `get_name()` - Human-readable name
- `check()` - Violation detection logic
4. Add to Manifest scan directories if needed
Example:
```php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class MyNew_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-NEW-01';
}
public function get_name(): string
{
return 'My New Rule';
}
public function get_description(): string
{
return 'Description of what this rule checks';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Whether this rule is called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Always false unless explicitly approved
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Detection logic
if ($violation_found) {
$this->add_violation(
$file_path,
$line_number,
"Violation message",
$code_snippet,
"How to fix",
'medium'
);
}
}
}
```
### Testing Rules
1. Create a temporary test file with violations
2. Run `php artisan rsx:check`
3. Verify violations are detected correctly
4. Clean up test files
## Migration from Monolith
The original 1921-line `CodeStandardsChecker.php` has been:
1. Archived to `/archived/CodeStandardsChecker.old.php`
2. Split into modular rule classes
3. Enhanced with auto-discovery via Manifest
4. Improved with better caching and performance
All original rule logic has been preserved exactly, ensuring no regression in code quality checks.
## Performance
- **Caching**: Sanitized file contents are cached to avoid repeated processing
- **Incremental Linting**: Files are only linted if changed since last check
- **Efficient Scanning**: Smart directory traversal skips excluded paths
## Manifest-Time Checking
By default, code quality rules run only when `php artisan rsx:check` is executed. However, certain critical rules can be configured to run during manifest builds to provide immediate feedback.
### When to Enable Manifest-Time Checking
**DO NOT** enable manifest-time checking unless you have explicit approval. Rules should only run at manifest-time if they:
1. Enforce critical framework conventions that would break the application
2. Need to provide immediate feedback before code execution
3. Have been specifically requested by framework maintainers
### Current Manifest-Time Rules
Only the following rules are approved for manifest-time execution:
- **BLADE-SCRIPT-01** (InlineScriptRule): Prevents inline JavaScript in Blade files (critical architecture violation)
- **JQHTML-INLINE-01** (JqhtmlInlineScriptRule): Prevents inline scripts/styles in Jqhtml template files (critical architecture violation)
All other rules should return `false` from `is_called_during_manifest_scan()`.
## Severity Levels
- **Critical**: Must fix immediately (e.g., fallback code)
- **High**: Should fix soon (e.g., mass assignment)
- **Medium**: Fix when convenient (e.g., naming conventions)
- **Low**: Minor issues (e.g., filename case)

View File

@@ -0,0 +1,553 @@
<?php
namespace App\RSpade\CodeQuality;
use App\RSpade\CodeQuality\CodeQuality_Violation;
use App\RSpade\CodeQuality\Support\CacheManager;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\ViolationCollector;
use App\RSpade\Core\Manifest\Manifest;
class CodeQualityChecker
{
protected static ?ViolationCollector $collector = null;
protected static ?CacheManager $cache_manager = null;
protected static array $rules = [];
protected static array $config = [];
public static function init(array $config = []): void
{
static::$collector = new ViolationCollector();
static::$cache_manager = new CacheManager();
static::$config = $config;
// Load all rules via auto-discovery
static::load_rules();
// Clean up old NPM bundle files on initialization
static::_cleanup_old_npm_bundles();
}
/**
* Clean up old NPM bundle files
* NPM bundles are cached based on package-lock.json + npm array + CWD
* Old bundles from different cache keys should be removed
*/
protected static function _cleanup_old_npm_bundles(): void
{
$bundle_dir = storage_path('rsx-build/bundles');
// Skip if directory doesn't exist yet
if (!is_dir($bundle_dir)) {
return;
}
// Find all npm_*.js files
$npm_bundles = glob($bundle_dir . '/npm_*.js');
if (empty($npm_bundles)) {
return;
}
// Keep the most recent 5 npm bundle files per bundle name
// Group by bundle name (npm_<bundlename>_<hash>.js)
$bundles_by_name = [];
foreach ($npm_bundles as $file) {
$filename = basename($file);
// Extract bundle name from npm_<bundlename>_<hash>.js
if (preg_match('/^npm_([^_]+)_[a-f0-9]{32}\.js$/', $filename, $matches)) {
$bundle_name = $matches[1];
if (!isset($bundles_by_name[$bundle_name])) {
$bundles_by_name[$bundle_name] = [];
}
$bundles_by_name[$bundle_name][] = [
'file' => $file,
'mtime' => filemtime($file)
];
}
}
// For each bundle name, keep only the 5 most recent files
foreach ($bundles_by_name as $bundle_name => $files) {
// Sort by modification time, newest first
usort($files, function($a, $b) {
return $b['mtime'] - $a['mtime'];
});
// Delete all but the most recent 5
$to_keep = 5;
for ($i = $to_keep; $i < count($files); $i++) {
@unlink($files[$i]['file']);
}
}
}
/**
* Load all rules via shared discovery logic
*/
protected static function load_rules(): void
{
// Check if we should exclude manifest-time rules (e.g., when running from rsx:check)
$exclude_manifest_time_rules = static::$config['exclude_manifest_time_rules'] ?? false;
// Use shared rule discovery that doesn't require manifest
static::$rules = Support\RuleDiscovery::discover_rules(
static::$collector,
static::$config,
false, // Get all rules, not just manifest scan ones
$exclude_manifest_time_rules // Exclude manifest-time rules if requested
);
}
/**
* Check a single file
*/
public static function check_file(string $file_path): void
{
// Get excluded directories from config
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
// Check if file is in any excluded directory
foreach ($excluded_dirs as $excluded_dir) {
if (str_contains($file_path, '/' . $excluded_dir . '/')) {
return;
}
}
// Skip CodeQuality infrastructure files, but allow checking Rules directory
// This enables meta rules to check other rules for code quality violations
if (str_contains($file_path, '/app/RSpade/CodeQuality/') &&
!str_contains($file_path, '/app/RSpade/CodeQuality/Rules/')) {
return;
}
// Get file extension
$extension = pathinfo($file_path, PATHINFO_EXTENSION);
// Check for syntax errors first
if ($extension === 'php') {
if (static::lint_php_file($file_path)) {
// Syntax error found, don't run other checks
return;
}
} elseif (in_array($extension, ['js', 'jsx', 'ts', 'tsx'])) {
if (static::lint_javascript_file($file_path)) {
// Syntax error found, don't run other checks
return;
}
} elseif ($extension === 'json') {
if (static::lint_json_file($file_path)) {
// Syntax error found, don't run other checks
return;
}
}
// Get cached sanitized file if available
$cached_data = static::$cache_manager->get_sanitized_file($file_path);
if ($cached_data === null) {
// Sanitize the file
$sanitized_data = FileSanitizer::sanitize($file_path);
// Cache the sanitized data
static::$cache_manager->set_sanitized_file($file_path, $sanitized_data);
} else {
$sanitized_data = $cached_data;
}
// Get metadata from manifest if available
try {
$metadata = Manifest::get_file($file_path) ?? [];
} catch (\Exception $e) {
$metadata = [];
}
// Check if this is a Console Command file
$is_console_command = str_contains($file_path, '/app/Console/Commands/');
// Run each rule on the file
foreach (static::$rules as $rule) {
// If this is a Console Command, only run rules that support them
if ($is_console_command && !$rule->supports_console_commands()) {
continue;
}
// Check if this rule applies to this file type
$applies = false;
foreach ($rule->get_file_patterns() as $pattern) {
if (static::matches_pattern($file_path, $pattern)) {
$applies = true;
break;
}
}
if (!$applies) {
continue;
}
// Check for rule-specific exception comment in original file content
$rule_id = $rule->get_id();
$exception_pattern = '@' . $rule_id . '-EXCEPTION';
$original_content = file_get_contents($file_path);
if (str_contains($original_content, $exception_pattern)) {
// Skip this rule for this file
continue;
}
// Run the rule
$rule->check($file_path, $sanitized_data['content'], $metadata);
}
}
/**
* Check multiple files
*/
public static function check_files(array $file_paths): void
{
// First run special directory-level checks for rules that need them
foreach (static::$rules as $rule) {
// Check for special check_root method (RootFilesRule)
if (method_exists($rule, 'check_root')) {
$rule->check_root();
}
// Check for special check_rsx method (RsxTestFilesRule)
if (method_exists($rule, 'check_rsx')) {
$rule->check_rsx();
}
// Check for special check_required_models method (RequiredModelsRule)
if (method_exists($rule, 'check_required_models')) {
$rule->check_required_models();
}
// Check for special check_rsx_commands method (RsxCommandsDeprecatedRule)
if (method_exists($rule, 'check_rsx_commands')) {
$rule->check_rsx_commands();
}
// Check for special check_commands method (CommandOrganizationRule)
if (method_exists($rule, 'check_commands')) {
$rule->check_commands();
}
}
// Then check individual files
foreach ($file_paths as $file_path) {
static::check_file($file_path);
}
}
/**
* Check all files in a directory
*/
public static function check_directory(string $directory, bool $recursive = true): void
{
// First run special directory-level checks for rules that need them
foreach (static::$rules as $rule) {
// Check for special check_root method (RootFilesRule)
if (method_exists($rule, 'check_root')) {
$rule->check_root();
}
// Check for special check_rsx method (RsxTestFilesRule)
if (method_exists($rule, 'check_rsx')) {
$rule->check_rsx();
}
// Check for special check_required_models method (RequiredModelsRule)
if (method_exists($rule, 'check_required_models')) {
$rule->check_required_models();
}
}
// Get all files - let rules filter by extension
$files = [];
if ($recursive) {
// Use RecursiveIteratorIterator for recursive scanning
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[] = $file->getPathname();
}
}
} else {
// Non-recursive - just scan immediate directory
$items = glob($directory . '/*');
$files = array_filter($items, 'is_file');
}
foreach ($files as $file) {
static::check_file($file);
}
}
/**
* Get the violation collector
*/
public static function get_collector(): ViolationCollector
{
return static::$collector;
}
/**
* Get all violations
*/
public static function get_violations(): array
{
return static::$collector->get_violations_as_arrays();
}
/**
* Clear cache
*/
public static function clear_cache(): void
{
static::$cache_manager->clear();
}
/**
* Check if a file path matches a pattern
*/
protected static function matches_pattern(string $file_path, string $pattern): bool
{
// Simple glob matching for file patterns like *.php, *.js
if (strpos($pattern, '*') === 0) {
// Pattern like *.php - check file extension
$extension = substr($pattern, 1); // Remove the *
return str_ends_with($file_path, $extension);
}
// For more complex patterns, use fnmatch if available
if (function_exists('fnmatch')) {
return fnmatch($pattern, basename($file_path));
}
// Fallback to simple string matching
return str_contains($file_path, $pattern);
}
/**
* Lint PHP file (from monolith line 536)
* Returns true if syntax error found
*/
protected static function lint_php_file(string $file_path): bool
{
// Get excluded directories from config
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
// Check if file is in any excluded directory
foreach ($excluded_dirs as $excluded_dir) {
if (str_contains($file_path, '/' . $excluded_dir . '/')) {
return false;
}
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return false;
}
// Create cache directory for lint flags
$cache_dir = function_exists('storage_path') ? storage_path('rsx-tmp/cache/php-lint-passed') : '/var/www/html/storage/rsx-tmp/cache/php-lint-passed';
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
// Generate flag file path (no .php extension to avoid IDE detection)
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$relative_path = str_replace($base_path . '/', '', $file_path);
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
// Check if lint was already passed
if (file_exists($flag_path)) {
$source_mtime = filemtime($file_path);
$flag_mtime = filemtime($flag_path);
if ($flag_mtime >= $source_mtime) {
// File hasn't changed since last successful lint
return false; // No errors
}
}
// Run PHP lint check
$command = sprintf('php -l %s 2>&1', escapeshellarg($file_path));
$output = shell_exec($command);
// Check if there's a syntax error
if (!str_contains($output, 'No syntax errors detected')) {
// Delete flag file if it exists (file now has errors)
if (file_exists($flag_path)) {
unlink($flag_path);
}
// Just capture the error as-is
static::$collector->add(
new CodeQuality_Violation(
'PHP-SYNTAX',
$file_path,
0,
trim($output),
'critical',
null,
'Fix the PHP syntax error before running other checks.'
)
);
return true; // Error found
}
// Create flag file to indicate successful lint
touch($flag_path);
return false; // No errors
}
/**
* Lint JavaScript file (from monolith line 602)
* Returns true if syntax error found
*/
protected static function lint_javascript_file(string $file_path): bool
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return false;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return false;
}
// Skip VS Code extension directory
if (str_contains($file_path, '/resource/vscode_extension/')) {
return false;
}
// Create cache directory for lint flags
$cache_dir = function_exists('storage_path') ? storage_path('rsx-tmp/cache/js-lint-passed') : '/var/www/html/storage/rsx-tmp/cache/js-lint-passed';
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
// Generate flag file path (no .js extension to avoid IDE detection)
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$relative_path = str_replace($base_path . '/', '', $file_path);
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
// Check if lint was already passed
if (file_exists($flag_path)) {
$source_mtime = filemtime($file_path);
$flag_mtime = filemtime($flag_path);
if ($flag_mtime >= $source_mtime) {
// File hasn't changed since last successful lint
return false; // No errors
}
}
// Run JavaScript syntax check using Node.js
$linter_path = $base_path . '/bin/js-linter.js';
if (!file_exists($linter_path)) {
// Linter script not found, skip linting
return false;
}
$command = sprintf('node %s %s 2>&1', escapeshellarg($linter_path), escapeshellarg($file_path));
$output = shell_exec($command);
// Check if there's a syntax error
if ($output && trim($output) !== '') {
// Delete flag file if it exists (file now has errors)
if (file_exists($flag_path)) {
unlink($flag_path);
}
// Parse error message for line number if available
$line_number = 0;
if (preg_match('/Line (\d+)/', $output, $matches)) {
$line_number = (int)$matches[1];
}
static::$collector->add(
new CodeQuality_Violation(
'JS-SYNTAX',
$file_path,
$line_number,
trim($output),
'critical',
null,
'Fix the JavaScript syntax error before running other checks.'
)
);
return true; // Error found
}
// Create flag file to indicate successful lint
touch($flag_path);
return false; // No errors
}
/**
* Lint JSON file (from monolith line 684)
* Returns true if syntax error found
*/
protected static function lint_json_file(string $file_path): bool
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return false;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return false;
}
// Skip VS Code extension directory
if (str_contains($file_path, '/resource/vscode_extension/')) {
return false;
}
$content = file_get_contents($file_path);
// Try to decode the JSON
json_decode($content);
// Check for JSON errors
if (json_last_error() !== JSON_ERROR_NONE) {
$error_message = json_last_error_msg();
// Try to find line number for common errors
$line_number = 0;
if (str_contains($error_message, 'Syntax error')) {
// Count lines up to the error position if possible
$lines = explode("\n", $content);
$line_number = count($lines); // Default to last line
}
static::$collector->add(
new CodeQuality_Violation(
'JSON-SYNTAX',
$file_path,
$line_number,
"JSON parse error: {$error_message}",
'critical',
null,
'Fix the JSON syntax error. Common issues: missing commas, trailing commas, unquoted keys.'
)
);
return true; // Error found
}
return false; // No errors
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\RSpade\CodeQuality;
#[Instantiatable]
class CodeQuality_Violation
{
public function __construct(
public readonly string $rule_id,
public readonly string $file_path,
public readonly int $line_number,
public readonly string $message,
public readonly string $severity,
public readonly ?string $code_snippet = null,
public readonly ?string $suggestion = null
) {}
public function to_array(): array
{
// Return in format expected by InspectCommand
return [
'file' => $this->file_path,
'line' => $this->line_number,
'type' => $this->rule_id,
'message' => $this->message,
'resolution' => $this->suggestion,
'code' => $this->code_snippet,
'severity' => $this->severity,
];
}
public function get_severity_weight(): int
{
return match($this->severity) {
'critical' => 4,
'high' => 3,
'medium' => 2,
'low' => 1,
'convention' => 0,
default => 2,
};
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace App\RSpade\CodeQuality\Parsers;
/**
* Lightweight SCSS context parser for code quality rules
*
* PURPOSE: This is NOT a full SCSS compiler/parser. It only tracks:
* - Selector nesting and building full selector paths
* - Property declarations within each selector context
* - Pseudo-state detection (:hover, :focus, :active)
*
* DESIGN: Simple state machine that builds selector context by tracking braces
* and nesting. Designed for code quality rules that need to understand what
* properties are set in hover/focus states vs base states.
*
* USAGE:
* $contexts = ScssContextParser::parse_contexts($scss_content);
* foreach ($contexts as $context) {
* if (ScssContextParser::is_in_hover_context($context['selector'])) {
* // Check properties...
* }
* }
*
* FUTURE: Can be extended to track @media queries, mixins, or other SCSS
* features as needed by new rules. Each code quality rule documents what
* parsing features it requires.
*/
class ScssContextParser
{
/**
* Parse SCSS content into selector contexts with their properties
*
* @param string $scss Raw SCSS content
* @return array Array of contexts, each with:
* - 'line': Line number where selector starts
* - 'selector': Full selector path (e.g., '.nav-link:hover')
* - 'properties': Associative array of property => value
* - 'is_hover': Boolean if selector contains :hover
* - 'is_focus': Boolean if selector contains :focus
* - 'is_active': Boolean if selector contains :active
*/
public static function parse_contexts(string $scss): array
{
$lines = explode("\n", $scss);
$contexts = [];
$selector_stack = [];
$current_context = null;
$brace_depth = 0;
$in_comment = false;
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_num = $i + 1;
$trimmed = trim($line);
// Skip empty lines
if (empty($trimmed)) {
continue;
}
// Handle multi-line comments
if (str_contains($line, '/*')) {
$in_comment = true;
}
if ($in_comment) {
if (str_contains($line, '*/')) {
$in_comment = false;
}
continue;
}
// Skip single-line comments
if (str_starts_with($trimmed, '//')) {
continue;
}
// Remove inline comments for processing
$clean_line = preg_replace('/\/\/.*$/', '', $line);
$clean_line = preg_replace('/\/\*.*?\*\//', '', $clean_line);
$trimmed_clean = trim($clean_line);
// Count braces before processing
$open_braces = substr_count($clean_line, '{');
$close_braces = substr_count($clean_line, '}');
// Handle closing braces - pop from selector stack
for ($j = 0; $j < $close_braces; $j++) {
if (!empty($selector_stack)) {
array_pop($selector_stack);
}
$brace_depth--;
// Save current context when closing its block
if ($current_context && $brace_depth < $current_context['depth']) {
$contexts[] = $current_context;
$current_context = null;
}
}
// Check if this line starts a new selector block
if ($open_braces > 0 && !empty($trimmed_clean)) {
// Extract the selector part (before the {)
$selector_part = trim(str_replace('{', '', $trimmed_clean));
// Skip @keyframes, @media, @import etc
if (str_starts_with($selector_part, '@')) {
$brace_depth += $open_braces;
continue;
}
// Build full selector path
$full_selector = self::build_selector_path($selector_stack, $selector_part);
// Push to stack for nested selectors
$selector_stack[] = $selector_part;
$brace_depth += $open_braces;
// Create new context
$current_context = [
'line' => $line_num,
'selector' => $full_selector,
'properties' => [],
'depth' => $brace_depth,
'is_hover' => str_contains($full_selector, ':hover'),
'is_focus' => str_contains($full_selector, ':focus'),
'is_active' => str_contains($full_selector, ':active')
];
} elseif ($open_braces > 0) {
// Opening brace without selector (continuation from previous line)
$brace_depth += $open_braces;
}
// Parse property declarations within current context
if ($current_context && $brace_depth === $current_context['depth']) {
if (preg_match('/^\s*([a-z-]+)\s*:\s*(.+?);?\s*$/i', $trimmed_clean, $matches)) {
$property = $matches[1];
$value = trim($matches[2], '; ');
$current_context['properties'][$property] = $value;
}
}
}
// Save any remaining context
if ($current_context) {
$contexts[] = $current_context;
}
return $contexts;
}
/**
* Build full selector path from selector stack
* Handles SCSS & parent reference properly
*/
private static function build_selector_path(array $stack, string $current): string
{
if (empty($stack)) {
return $current;
}
$parent = implode(' ', $stack);
// Handle & parent reference
if (str_starts_with($current, '&')) {
// Replace & with the immediate parent (last item in stack)
$immediate_parent = end($stack);
$current = str_replace('&', '', $current);
// Remove last item and rebuild
$stack_without_last = array_slice($stack, 0, -1);
if (empty($stack_without_last)) {
return $immediate_parent . $current;
}
return implode(' ', $stack_without_last) . ' ' . $immediate_parent . $current;
}
// Handle nested selectors without &
return $parent . ' ' . $current;
}
/**
* Check if a selector represents a hover/focus/active state
*/
public static function is_in_hover_context(string $selector): bool
{
return str_contains($selector, ':hover') ||
str_contains($selector, ':focus') ||
str_contains($selector, ':active');
}
/**
* Get the base selector without pseudo-states
* Example: '.btn:hover' => '.btn'
*/
public static function get_base_selector(string $selector): string
{
return preg_replace('/:(hover|focus|active|visited|disabled)/', '', $selector);
}
/**
* Compare properties between two contexts to find differences
* Useful for detecting redundant declarations or actual changes
*/
public static function compare_properties(array $base_props, array $state_props): array
{
$differences = [
'added' => [],
'changed' => [],
'same' => [],
'removed' => []
];
foreach ($state_props as $prop => $value) {
if (!isset($base_props[$prop])) {
$differences['added'][$prop] = $value;
} elseif ($base_props[$prop] !== $value) {
$differences['changed'][$prop] = [
'from' => $base_props[$prop],
'to' => $value
];
} else {
$differences['same'][$prop] = $value;
}
}
foreach ($base_props as $prop => $value) {
if (!isset($state_props[$prop])) {
$differences['removed'][$prop] = $value;
}
}
return $differences;
}
/**
* Check if a property is position/size related
* These are typically prohibited in hover states
*/
public static function is_position_property(string $property): bool
{
$position_properties = [
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'top', 'right', 'bottom', 'left',
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'font-size', 'line-height', 'letter-spacing', 'word-spacing'
];
return in_array($property, $position_properties);
}
/**
* Check if a property is visual-only (safe for hover)
* These don't affect layout or position
*/
public static function is_visual_only_property(string $property): bool
{
$visual_properties = [
'color', 'background-color', 'background', 'background-image',
'opacity', 'visibility',
'border-color', 'outline', 'outline-color',
'text-decoration', 'text-decoration-color',
'box-shadow', 'text-shadow',
'filter', 'backdrop-filter',
'cursor'
];
// Check for exact match or if property starts with one of these
foreach ($visual_properties as $visual_prop) {
if ($property === $visual_prop || str_starts_with($property, $visual_prop . '-')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class BladeExtends_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-EXTENDS-01';
}
public function get_name(): string
{
return 'Blade @extends Syntax Check';
}
public function get_description(): string
{
return "Detects incorrect @extends('rsx:: usage - should use @rsx_extends instead";
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check blade files for incorrect @extends('rsx:: usage
* The correct syntax is @rsx_extends('layout_name'), not @extends('rsx::...)
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in rsx/ directory
if (!str_contains($file_path, '/rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for @extends('rsx:: pattern (with single or double quotes)
if (preg_match('/@extends\s*\(\s*[\'"]rsx::([^\'"]+)[\'"]/', $line, $matches)) {
$layout_reference = $matches[1] ?? '';
// Build suggestion
$suggestion = "The @extends directive with 'rsx::' namespace is incorrect.\n";
$suggestion .= "Use the @rsx_extends directive instead:\n";
$suggestion .= " Replace: @extends('rsx::{$layout_reference}')\n";
$suggestion .= " With: @rsx_extends('{$layout_reference}')\n\n";
$suggestion .= "The @rsx_extends directive uses the RSX ID system to locate layouts ";
$suggestion .= "by their RSX ID rather than file paths.";
$this->add_violation(
$file_path,
$line_number,
"Incorrect use of @extends('rsx::...'). Use @rsx_extends instead.",
trim($line),
$suggestion,
'high'
);
}
}
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Enforces path-agnostic class references in Blade templates by preventing direct FQCN usage
*
* Blade templates should reference RSX classes by simple name only, not by FQCN.
* Direct references like \Rsx\Models\User_Model or \App\RSpade\Core\Session\Session::method()
* are not allowed - use User_Model or Session instead.
*
* Note: use statements for Rsx\ classes ARE allowed in PHP blocks within Blade files,
* though they are unnecessary due to the autoloader.
*/
class BladeFqcnUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-RSX-FQCN-01';
}
public function get_name(): string
{
return 'Blade RSX FQCN Usage Validator';
}
public function get_description(): string
{
return 'Prevents direct FQCN references to Rsx classes in Blade templates';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'high';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Pattern to match \Rsx\... FQCN references
// Looks for \Rsx\ followed by class path components
// Must be preceded and followed by non-alphanumeric characters (except \ for namespace)
$pattern = '/(?<![a-zA-Z0-9])\\\\Rsx\\\\[a-zA-Z0-9_\\\\]+/';
if (!preg_match_all($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
return;
}
// Get manifest data once for lookups
$manifest = Manifest::get_all();
foreach ($matches[0] as $match) {
$fqcn = $match[0];
$offset = $match[1];
// Clean up the FQCN - remove leading backslash and normalize
$clean_fqcn = ltrim($fqcn, '\\');
// Extract simple class name from FQCN
$parts = explode('\\', $clean_fqcn);
$simple_name = end($parts);
// Check if this FQCN actually exists in the manifest
// This prevents false positives for non-existent classes
$class_exists = false;
try {
// Try to find the class by simple name
$class_metadata = Manifest::php_get_metadata_by_class($simple_name);
// Verify the FQCN matches
if ($class_metadata && isset($class_metadata['fqcn'])) {
$manifest_fqcn = $class_metadata['fqcn'];
// Compare without leading backslash
if (ltrim($manifest_fqcn, '\\') === $clean_fqcn) {
$class_exists = true;
}
}
} catch (\RuntimeException $e) {
// Class not found in manifest - not a real class reference
continue;
}
// Only report violation if the class actually exists
if (!$class_exists) {
continue;
}
// Calculate line number from offset
$line = 1;
for ($i = 0; $i < $offset; $i++) {
if ($contents[$i] === "\n") {
$line++;
}
}
// Extract the line containing the violation for context
$lines = explode("\n", $contents);
$code_snippet = '';
if ($line > 0 && $line <= count($lines)) {
$code_snippet = trim($lines[$line - 1]);
}
$message = "Direct FQCN reference '{$fqcn}' in Blade template is not allowed. RSX classes are path-agnostic and should be referenced by simple name only.";
$suggestion = "Replace '{$fqcn}' with '{$simple_name}'. The autoloader will automatically resolve the class.";
$this->add_violation(
$file_path,
$line,
$message,
$code_snippet,
$suggestion,
'high'
);
}
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class InlineScript_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-SCRIPT-01';
}
public function get_name(): string
{
return 'Blade Inline Script Check';
}
public function get_description(): string
{
return 'Enforces no inline JavaScript in blade views - all JavaScript must be in separate ES6 class files';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* This rule should run during manifest scan to provide immediate feedback
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* inline scripts in Blade files violate critical framework architecture patterns.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update to extract inline script violations
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
// Skip layouts (they can have script tags for loading external scripts)
if (str_contains($file_path, 'layout') || str_contains($file_path, 'Layout')) {
return null;
}
$lines = explode("\n", $contents);
$violations = [];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for <script> tags
if (preg_match('/<script\b[^>]*>(?!.*src=)/i', $line)) {
$violations[] = [
'type' => 'inline_script',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need to find first violation
}
// Check for jQuery ready patterns
if (preg_match('/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line) ||
preg_match('/\$\s*\(\s*function\s*\(/', $line) ||
preg_match('/jQuery\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line)) {
$violations[] = [
'type' => 'jquery_ready',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need to find first violation
}
}
if (!empty($violations)) {
return ['inline_script_violations' => $violations];
}
return null;
}
/**
* Check blade file for inline script violations stored in metadata
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip layouts
if (str_contains($file_path, 'layout') || str_contains($file_path, 'Layout')) {
return;
}
// Check for violations in code quality metadata
if (isset($metadata['code_quality_metadata']['BLADE-SCRIPT-01']['inline_script_violations'])) {
$violations = $metadata['code_quality_metadata']['BLADE-SCRIPT-01']['inline_script_violations'];
// Throw on first violation
foreach ($violations as $violation) {
$rsx_id = $this->extract_rsx_id($contents);
if ($violation['type'] === 'inline_script') {
$error_message = "Code Quality Violation (BLADE-SCRIPT-01) - Inline Script in Blade View\n\n";
$error_message .= "CRITICAL: Inline <script> tags are not allowed in blade views\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$violation['line']}\n";
$error_message .= "Code: {$violation['code']}\n\n";
$error_message .= $this->get_detailed_remediation($file_path, $rsx_id);
} else {
$error_message = "Code Quality Violation (BLADE-SCRIPT-01) - jQuery Ready Pattern in Blade View\n\n";
$error_message .= "CRITICAL: jQuery ready patterns are not allowed - use ES6 class with on_app_ready()\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$violation['line']}\n";
$error_message .= "Code: {$violation['code']}\n\n";
$error_message .= $this->get_detailed_remediation($file_path, $rsx_id);
}
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($file_path),
$violation['line']
);
}
}
}
/**
* Extract RSX ID from blade file content
*/
private function extract_rsx_id(string $contents): ?string
{
if (preg_match('/@rsx_id\s*\(\s*[\'"]([^\'"]+)[\'"]/', $contents, $matches)) {
return $matches[1];
}
return null;
}
/**
* Get detailed remediation instructions
*/
private function get_detailed_remediation(string $file_path, ?string $rsx_id): string
{
// Determine the JS filename and class name
$path_parts = pathinfo($file_path);
$blade_name = str_replace('.blade', '', $path_parts['filename']);
// If we have an RSX ID, use it as the class name
if ($rsx_id) {
$class_name = str_replace('.', '_', $rsx_id);
$js_filename = strtolower(str_replace('_', '_', $class_name)) . '.js';
} else {
// Fallback to blade filename
$class_name = ucfirst($blade_name);
$js_filename = $blade_name . '.js';
}
$js_path = dirname($file_path) . '/' . $js_filename;
return "FRAMEWORK CONVENTION: JavaScript for blade views must be in separate ES6 class files.
REQUIRED STEPS:
1. Create a JavaScript file: {$js_path}
2. Name the ES6 class exactly: {$class_name}
3. Use the on_app_ready() lifecycle method for initialization
4. Check for the view's presence using the RSX ID class selector
EXAMPLE IMPLEMENTATION for {$js_filename}:
/**
* JavaScript for {$class_name} view
*/
class {$class_name} {
/**
* Initialize when app is ready
* This method is automatically called by RSX framework
* No manual registration is required
*/
static on_app_ready() {
// CRITICAL: Only initialize if we're on this specific view
// The RSX framework adds the RSX ID as a class to the body tag
if (!\$('.{$class_name}').exists()) {
return;
}
console.log('{$class_name} view initialized');
// Add your view-specific JavaScript here
// Example: bind events, initialize components, etc.
// If you need to bind events to dynamically loaded components:
\$('#load-component-btn').on('click', function() {
\$('#dynamic-component-target').component('User_Card', {
data: {
name: 'Dynamic User',
email: 'dynamic@example.com'
}
});
});
}
}
KEY CONVENTIONS:
- Class name MUST match the RSX ID (with dots replaced by underscores)
- MUST use static on_app_ready() method (called automatically by framework)
- MUST check for view presence using \$('.{$class_name}').exists()
- MUST return early if view is not present (prevents code from running on wrong pages)
- NO manual registration needed - framework auto-discovers and calls on_app_ready()
- NO $(document).ready() or jQuery ready patterns allowed
- NO window.onload or DOMContentLoaded events allowed
WHY THIS MATTERS:
- Separation of concerns: HTML structure separate from behavior
- Framework integration: Automatic lifecycle management
- Performance: JavaScript only loads and runs where needed
- Maintainability: Clear file organization and naming conventions
- LLM-friendly: Predictable patterns that AI assistants can follow
The RSX framework will automatically:
1. Discover your ES6 class via the manifest
2. Call on_app_ready() after all components are initialized
3. Ensure proper execution order through lifecycle phases";
}
}

View File

@@ -0,0 +1,288 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class UnbalancedTags_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-TAGS-01';
}
public function get_name(): string
{
return 'Balanced HTML Tags in Control Flow';
}
public function get_description(): string
{
return 'Enforces that HTML tags opened within control flow blocks (@if/@foreach/etc) must be closed within the same block';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
public function is_called_during_manifest_scan(): bool
{
return false;
}
/**
* Check blade file for unbalanced HTML tags in control flow blocks
*
* Simpler rule: If a block's first non-whitespace content is an opening HTML tag,
* that tag must be closed before the block ends.
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Parse the blade file into control flow blocks
$blocks = $this->parse_control_flow_blocks($contents);
foreach ($blocks as $block) {
$violation = $this->check_first_tag_closed($block['content'], $block['type']);
if ($violation !== null) {
$error_message = "Code Quality Violation (BLADE-TAGS-01) - Unbalanced HTML Tags in Control Flow\n\n";
$error_message .= "CRITICAL: Opening tag in control flow block must be closed within the same block\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Block: {$block['type']} at line {$block['start_line']}\n";
$error_message .= "First tag: <{$violation['tag']}> at line {$violation['opening_line']}\n";
$error_message .= "Problem: {$violation['message']}\n\n";
$error_message .= $this->get_remediation($block['type']);
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($file_path),
$block['start_line'] + $violation['opening_line'] - 1
);
}
}
}
/**
* Parse control flow blocks from blade content
*/
private function parse_control_flow_blocks(string $contents): array
{
$lines = explode("\n", $contents);
$blocks = [];
$stack = [];
// Control flow patterns
$start_patterns = [
'@if' => '@endif',
'@elseif' => '@endif',
'@else' => '@endif',
'@foreach' => '@endforeach',
'@for' => '@endfor',
'@while' => '@endwhile',
'@unless' => '@endunless',
'<?php if' => '<?php endif',
'<?php foreach' => '<?php endforeach',
'<?php for' => '<?php endfor',
'<?php while' => '<?php endwhile',
];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for block start
foreach ($start_patterns as $start => $end) {
if (preg_match('/^\s*' . preg_quote($start, '/') . '\b/', $line)) {
// Special handling for @else and @elseif - close previous block first
if (in_array($start, ['@elseif', '@else']) && !empty($stack)) {
$prev_block = array_pop($stack);
$prev_block['end_line'] = $line_number - 1;
$blocks[] = $prev_block;
}
$stack[] = [
'type' => $start,
'start_line' => $line_number,
'content' => '',
'lines' => [],
];
continue 2;
}
}
// Check for block end
foreach ($start_patterns as $start => $end) {
if (preg_match('/^\s*' . preg_quote($end, '/') . '\b/', $line)) {
if (!empty($stack)) {
$block = array_pop($stack);
$block['end_line'] = $line_number;
$blocks[] = $block;
}
continue 2;
}
}
// Add line to current block
if (!empty($stack)) {
$stack[count($stack) - 1]['content'] .= $line . "\n";
$stack[count($stack) - 1]['lines'][$line_number] = $line;
}
}
return $blocks;
}
/**
* Check if the first HTML tag in a block is closed within that block
* Returns null if OK, or array with violation details if not
*/
private function check_first_tag_closed(string $content, string $block_type): ?array
{
$lines = explode("\n", $content);
// Void elements that don't need closing tags
$void_elements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr'];
// Find the first non-whitespace line with an opening HTML tag
$first_tag = null;
$first_tag_line = null;
foreach ($lines as $line_num => $line) {
$trimmed = trim($line);
// Skip empty lines and blade comments
if (empty($trimmed) || str_starts_with($trimmed, '{{--')) {
continue;
}
// Look for opening HTML tag at start of line (after whitespace)
if (preg_match('/^<([a-zA-Z][a-zA-Z0-9_-]*)[^>]*>/i', $trimmed, $match)) {
$tag_name = strtolower($match[1]);
// Skip void elements
if (in_array($tag_name, $void_elements)) {
continue;
}
// Skip self-closing tags
if (str_ends_with(trim($match[0]), '/>')) {
continue;
}
// Found first opening tag
$first_tag = $tag_name;
$first_tag_line = $line_num + 1;
break;
}
// If we hit non-tag content first, no violation possible
if (!empty($trimmed)) {
return null;
}
}
// If no opening tag found, nothing to check
if ($first_tag === null) {
return null;
}
// Now check if the closing tag appears in the block
$closing_tag = "</{$first_tag}>";
foreach ($lines as $line) {
if (stripos($line, $closing_tag) !== false) {
// Found the closing tag - all good
return null;
}
}
// Closing tag not found in block
return [
'tag' => $first_tag,
'opening_line' => $first_tag_line,
'message' => "Closing tag </{$first_tag}> not found before end of {$block_type} block",
];
}
/**
* Get detailed remediation instructions
*/
private function get_remediation(string $block_type): string
{
return "FRAMEWORK CONVENTION: HTML tags must be balanced within control flow blocks.
This antipattern is commonly called \"Split Tag Conditionals\" or \"Broken HTML Nesting\".
PROBLEM: Control flow statements split what should be a single lexical unit (a complete HTML element).
WHY THIS IS FORBIDDEN:
1. Parser confusion - Syntax highlighters and formatters can't parse it correctly
2. Maintainability nightmare - Hard to see complete element structure
3. Error-prone - Easy to create invalid HTML when modifying
4. Mental overhead - Reader must mentally reconstruct tags across control flow
CORRECT ALTERNATIVES:
Option 1: Inline ternary (best for simple cases)
────────────────────────────────────────────────
@if(\$active)
<div class=\"card active\">Content</div>
@else
<div class=\"card inactive\">Content</div>
@endif
Option 2: Pre-compute classes (best for complex attributes)
────────────────────────────────────────────────────────────
@php
\$card_class = \$active ? 'active' : 'inactive';
@endphp
<div class=\"card {{ \$card_class }}\">
Content
</div>
Option 3: Duplicate complete tags (best when tags differ significantly)
────────────────────────────────────────────────────────────────────────
@if(\$show_form)
<form action=\"/submit\" method=\"POST\">
<input type=\"text\" name=\"value\">
<button>Submit</button>
</form>
@else
<div class=\"info-message\">
Form is disabled
</div>
@endif
NEVER DO THIS:
────────────────
@if(\$required)
<select required>
@else
<select>
@endif
<option>Value</option>
</select>
CORRECT:
───────────
@if(\$required)
<select required>
<option>Value</option>
</select>
@else
<select>
<option>Value</option>
</select>
@endif
PRINCIPLE: Control flow should NEVER split lexical units. HTML elements are atomic structures - keep them whole.";
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\RSpade\CodeQuality\Rules;
use App\RSpade\CodeQuality\CodeQuality_Violation;
use App\RSpade\CodeQuality\Support\ViolationCollector;
#[Instantiatable]
abstract class CodeQualityRule_Abstract
{
protected ViolationCollector $collector;
protected array $config = [];
protected bool $enabled = true;
public function __construct(ViolationCollector $collector, array $config = [])
{
$this->collector = $collector;
$this->config = $config;
$this->enabled = $config['enabled'] ?? true;
}
/**
* Get the unique rule identifier (e.g., 'PHP-SC-001')
*/
abstract public function get_id(): string;
/**
* Get human-readable rule name
*/
abstract public function get_name(): string;
/**
* Get rule description
*/
abstract public function get_description(): string;
/**
* Get file patterns this rule applies to (e.g., ['*.php', '*.js'])
*/
abstract public function get_file_patterns(): array;
/**
* Check a file for violations
*
* @param string $file_path Absolute path to file
* @param string $contents File contents (may be sanitized)
* @param array $metadata Additional metadata from manifest
*/
abstract public function check(string $file_path, string $contents, array $metadata = []): void;
/**
* Whether this rule supports checking Console Commands
*
* Rules that return true here will be given Console Command files to check
* when using default paths or when a specific Console Command file is provided.
* Rules supporting Console Commands MUST NOT rely on manifest metadata as
* Console Commands are not indexed in the manifest.
*
* @return bool
*/
public function supports_console_commands(): bool
{
return false;
}
/**
* Check if this rule is enabled
*/
public function is_enabled(): bool
{
return $this->enabled;
}
/**
* Check if this rule should be called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*
* @return bool
*/
public function is_called_during_manifest_scan(): bool
{
return false;
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'medium';
}
/**
* Add a violation
*/
protected function add_violation(
string $file_path,
int $line_number,
string $message,
?string $code_snippet = null,
?string $suggestion = null,
?string $severity = null
): void {
$violation = new CodeQuality_Violation(
rule_id: $this->get_id(),
file_path: $file_path,
line_number: $line_number,
message: $message,
severity: $severity ?? $this->get_default_severity(),
code_snippet: $code_snippet,
suggestion: $suggestion
);
$this->collector->add($violation);
}
/**
* Extract code snippet around a line
*/
protected function get_code_snippet(array $lines, int $line_index, int $context = 2): string
{
$start = max(0, $line_index - $context);
$end = min(count($lines) - 1, $line_index + $context);
$snippet = [];
for ($i = $start; $i <= $end; $i++) {
$prefix = $i === $line_index ? '>>> ' : ' ';
$snippet[] = $prefix . ($i + 1) . ': ' . $lines[$i];
}
return implode("\n", $snippet);
}
/**
* Get all PHP files in the Console Commands directory
*
* This helper allows rules to optionally include Console Commands in their checks
* without requiring these files to be in the manifest. Rules using this helper
* MUST NOT rely on manifest metadata since Console Commands are not indexed.
*
* @return array Array of absolute file paths to PHP files in app/Console/Commands
*/
protected static function get_console_command_files(): array
{
$commands_dir = base_path('app/Console/Commands');
if (!is_dir($commands_dir)) {
return [];
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($commands_dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* Check if a file is a Console Command
*
* @param string $file_path The file path to check
* @return bool True if the file is in app/Console/Commands
*/
protected static function is_console_command(string $file_path): bool
{
$commands_dir = base_path('app/Console/Commands');
$normalized_path = str_replace('\\', '/', $file_path);
$normalized_commands_dir = str_replace('\\', '/', $commands_dir);
return str_starts_with($normalized_path, $normalized_commands_dir);
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class AbstractClassNaming_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'ABSTRACT-CLASS-01';
}
public function get_name(): string
{
return 'Abstract Class Naming Convention';
}
public function get_description(): string
{
return 'Ensures abstract classes follow RSX naming conventions with _Abstract suffix';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js'];
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip -temp files
if (str_contains($file_path, '-temp.')) {
return;
}
// Only check files in ./rsx and ./app/RSpade
$is_rsx = str_contains($file_path, '/rsx/');
$is_rspade = str_contains($file_path, '/app/RSpade/');
if (!$is_rsx && !$is_rspade) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if ($is_rspade && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
$filename = basename($file_path);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
// Handle PHP files
if ($extension === 'php') {
$this->check_php_abstract_class($file_path, $contents, $metadata);
}
// Handle JavaScript files
elseif ($extension === 'js') {
$this->check_js_abstract_class($file_path, $contents, $metadata);
}
}
private function check_php_abstract_class(string $file_path, string $contents, array $metadata): void
{
// Check if this is an abstract class
if (!preg_match('/^\s*abstract\s+class\s+(\w+)/m', $contents, $matches)) {
return; // Not an abstract class
}
$class_name = $matches[1];
$filename = basename($file_path);
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// Check class name ends with _Abstract
if (!str_ends_with($class_name, '_Abstract')) {
$this->add_violation(
$file_path,
0,
"Abstract class '$class_name' must end with '_Abstract'",
"abstract class $class_name",
$this->get_abstract_class_remediation($class_name, $filename, true),
'high'
);
return; // Don't check filename if class name is wrong
}
// Check filename ends with _abstract.php or Abstract.php
if (!str_ends_with($filename, '_abstract.php') && !str_ends_with($filename, 'Abstract.php')) {
$this->add_violation(
$file_path,
0,
"Abstract class file '$filename' must end with '_abstract.php' or 'Abstract.php'",
"File: $filename",
$this->get_abstract_filename_remediation($class_name, $filename),
'medium'
);
}
}
private function check_js_abstract_class(string $file_path, string $contents, array $metadata): void
{
// Check for classes ending with _Abstract
if (!isset($metadata['class'])) {
return;
}
$class_name = $metadata['class'];
// Only check classes that end with _Abstract
if (!str_ends_with($class_name, '_Abstract')) {
return;
}
$filename = basename($file_path);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
// Check filename ends with _abstract.js or Abstract.js
$valid_endings = ["_abstract.$extension", "Abstract.$extension"];
$valid = false;
foreach ($valid_endings as $ending) {
if (str_ends_with($filename, $ending)) {
$valid = true;
break;
}
}
if (!$valid) {
$this->add_violation(
$file_path,
0,
"Abstract class file '$filename' must end with '_abstract.$extension' or 'Abstract.$extension'",
"File: $filename",
$this->get_abstract_filename_remediation($class_name, $filename),
'medium'
);
}
}
private function get_abstract_class_remediation(string $class_name, string $filename, bool $is_php): string
{
// Determine suggested class name based on current naming
$suggested_class = $this->suggest_abstract_class_name($class_name);
$is_rsx = str_contains($filename, '_');
return "ABSTRACT CLASS NAMING CONVENTION
Abstract classes must follow RSX naming patterns:
- Class name must end with '_Abstract'
- Filename must end with '_abstract.php' or 'Abstract.php'
CURRENT CLASS: $class_name
SUGGESTED CLASS: $suggested_class
SUGGESTED FIXES:
1. Rename class from '$class_name' to '$suggested_class'
2. Rename file to match:
- For /rsx: Use lowercase convention (e.g., " . strtolower(str_replace('_', '_', $suggested_class)) . ".php)
- For /app/RSpade: Use exact match (e.g., $suggested_class.php)
3. Update all references to this class
WHY THIS MATTERS:
- Makes abstract classes immediately identifiable
- Enables framework introspection and auto-discovery
- Maintains consistent naming patterns across the codebase";
}
private function get_abstract_filename_remediation(string $class_name, string $filename): string
{
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$lowercase_suggestion = strtolower(str_replace('_', '_', $class_name)) . ".$extension";
$exact_suggestion = $class_name . ".$extension";
return "ABSTRACT CLASS FILENAME CONVENTION
Abstract class files must end with '_abstract.$extension' or 'Abstract.$extension'
CURRENT FILE: $filename
CLASS NAME: $class_name
VALID FILENAME PATTERNS:
- Lowercase with underscore: *_abstract.$extension
- Uppercase suffix: *Abstract.$extension
SUGGESTED FILENAMES:
- For /rsx: $lowercase_suggestion
- For /app/RSpade: $exact_suggestion
Note: Both patterns are valid in either directory.";
}
private function suggest_abstract_class_name(string $current_name): string
{
// If class name contains 'Abstract' but not at the end
if (stripos($current_name, 'Abstract') !== false && !str_ends_with($current_name, '_Abstract') && !str_ends_with($current_name, 'Abstract')) {
// Remove 'Abstract' from wherever it is
$without_abstract = preg_replace('/Abstract/i', '', $current_name);
$without_abstract = trim($without_abstract, '_');
// If the class has underscores, add _Abstract
if (str_contains($without_abstract, '_')) {
return $without_abstract . '_Abstract';
} else {
// For non-underscore classes, add Abstract at the end
return $without_abstract . 'Abstract';
}
}
// If class doesn't contain 'Abstract' at all
if (str_contains($current_name, '_')) {
return $current_name . '_Abstract';
} else {
return $current_name . 'Abstract';
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class Assignment_Comparison_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'COMMON-ASSIGN-01';
}
public function get_name(): string
{
return 'Assignment vs Comparison Check';
}
public function get_description(): string
{
return 'Detects assignment operator (=) used where comparison (== or ===) expected';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.jsx', '*.ts', '*.tsx'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check if file is in allowed directories using same logic as rsx:check command
*/
private function is_file_in_allowed_directories(string $file_path): bool
{
// Get scan directories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Special case: Allow Console Command files
if (str_starts_with($relative_path, 'app/Console/Commands/')) {
return true;
}
// Check against configured scan directories
foreach ($scan_directories as $scan_path) {
// Skip specific file entries in scan_directories
if (str_contains($scan_path, '.')) {
// This is a specific file, check exact match
if ($relative_path === $scan_path) {
return true;
}
} else {
// This is a directory, check if file is within it
if (str_starts_with($relative_path, rtrim($scan_path, '/') . '/') ||
rtrim($relative_path, '/') === rtrim($scan_path, '/')) {
return true;
}
}
}
return false;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Use the same directory filtering logic as rsx:check command
if (!$this->is_file_in_allowed_directories($file_path)) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Determine file type
$is_php = str_ends_with($file_path, '.php');
$is_js = str_ends_with($file_path, '.js') || str_ends_with($file_path, '.jsx') ||
str_ends_with($file_path, '.ts') || str_ends_with($file_path, '.tsx');
if (!$is_php && !$is_js) {
return;
}
// Use original file content directly (no sanitization)
$original_content = file_get_contents($file_path);
$lines = explode("\n", $original_content);
// Process each line individually
// Note: We're letting multi-line conditions slide for simplicity - this catches 99% of violations
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip empty lines
if (trim($line) === '') {
continue;
}
// Skip lines that are just comments (start with //)
$trimmed = trim($line);
if (str_starts_with($trimmed, '//')) {
continue;
}
// Check for single = in if statement condition (must have complete condition on same line)
if (preg_match('/\bif\s*\(([^)]+)\)/', $line, $match)) {
$condition = $match[1];
// Skip if there's a // comment before the if statement
if (preg_match('/\/\/.*\bif\s*\(/', $line)) {
continue;
}
// Remove quoted strings to avoid false positives from regex patterns
// This prevents flagging preg_match('/pattern=value/', $var)
$condition_no_quotes = preg_replace('/([\'"])[^\\1]*?\\1/', '""', $condition);
// Check for single = that's not part of ==, ===, !=, !==, <=, >=
// Must have non-equals char before and after the single =
if (preg_match('/[^=!<>]\s*=\s*[^=]/', $condition_no_quotes)) {
// Double-check it's not a comparison operator
if (!preg_match('/(?:==|===|!=|!==|<=|>=)/', $condition_no_quotes)) {
$this->add_violation(
$file_path,
$line_number,
"Assignment operator (=) used in if statement where comparison expected.",
trim($line),
"Assignment and truthiness checks must be on separate lines. " .
"The pattern 'if (\$var = function())' is not acceptable in RSpade code. " .
"Split into two lines: '\$var = function(); if (\$var) { ... }'. " .
"If you meant comparison, use == or === instead of =. " .
"This rule enforces code clarity by separating assignment from condition evaluation.",
'high'
);
}
}
}
// Skip while statements - assignment in while is acceptable
// The pattern while ($var = function()) is allowed for iteration
// Check for single = in for loop condition (middle part)
if (preg_match('/\bfor\s*\(([^;]*);([^;]*);([^)]*)\)/', $line, $match)) {
$condition = $match[2]; // The middle part is the condition
// Skip if there's a // comment before the for statement
if (preg_match('/\/\/.*\bfor\s*\(/', $line)) {
continue;
}
// Remove quoted strings to avoid false positives
$condition_no_quotes = preg_replace('/([\'"])[^\\1]*?\\1/', '""', $condition);
if (trim($condition_no_quotes) && preg_match('/[^=!<>]\s*=\s*[^=]/', $condition_no_quotes)) {
if (!preg_match('/(?:==|===|!=|!==|<=|>=)/', $condition_no_quotes)) {
$this->add_violation(
$file_path,
$line_number,
"Assignment operator (=) used in for loop condition where comparison expected.",
trim($line),
"Use == or === for comparison in the for loop condition (second part). " .
"Assignment in the condition will always evaluate to the assigned value, not perform a comparison. " .
"Example: change 'for(i=0; i=5; i++)' to 'for(i=0; i==5; i++)'.",
'high'
);
}
}
}
// Skip do...while statements - assignment in while is acceptable
// The pattern } while ($var = function()) is allowed for iteration
// PHP-specific: Check for single = in elseif statement
if ($is_php && preg_match('/\belseif\s*\(([^)]+)\)/', $line, $match)) {
$condition = $match[1];
// Skip if there's a // comment before the elseif
if (preg_match('/\/\/.*\belseif\s*\(/', $line)) {
continue;
}
// Remove quoted strings to avoid false positives
$condition_no_quotes = preg_replace('/([\'"])[^\\1]*?\\1/', '""', $condition);
if (preg_match('/[^=!<>]\s*=\s*[^=]/', $condition_no_quotes)) {
if (!preg_match('/(?:==|===|!=|!==|<=|>=)/', $condition_no_quotes)) {
$this->add_violation(
$file_path,
$line_number,
"Assignment operator (=) used in elseif statement where comparison expected.",
trim($line),
"Use == or === for comparison in elseif statements. " .
"Assignment in an elseif condition will execute the assignment and evaluate the assigned value, not perform a comparison. " .
"Example: change 'elseif(x = 5)' to 'elseif(x == 5)' or 'elseif(x === 5)'.",
'high'
);
}
}
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class CommandOrganization_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CMD-ORG-01';
}
public function get_name(): string
{
return 'Command Organization Check';
}
public function get_description(): string
{
return 'Ensures commands are organized in proper subdirectories based on signature';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Special method for checking commands organization
* Only runs when using default paths
*/
public function check_commands(): void
{
// Only run when using default paths
if (!($this->config['using_default_paths'] ?? false)) {
return;
}
$commands_dir = base_path('app/Console/Commands');
if (!is_dir($commands_dir)) {
return;
}
// Get files in root Commands directory only (not subdirectories)
$files = glob($commands_dir . '/*.php');
foreach ($files as $file) {
$filename = basename($file);
$content = file_get_contents($file);
// Check for rsx: commands
if (preg_match('/\$signature\s*=\s*[\'"]rsx:/i', $content)) {
$this->add_violation(
$file,
0,
"RSX command found in root Commands directory",
"File: $filename contains 'rsx:' command signature",
"COMMAND ORGANIZATION VIOLATION\n\n" .
"This command has signature starting with 'rsx:' but is not in the Rsx subdirectory.\n\n" .
"REQUIRED ACTION:\n" .
"Move this file to app/RSpade/Commands/Rsx/ or one of its subdirectories.\n\n" .
"WHY THIS MATTERS:\n" .
"- All RSX framework commands must be organized in the Rsx subdirectory\n" .
"- This keeps framework commands separate from application commands\n" .
"- Maintains consistent command organization\n" .
"- Makes it easier to find related commands\n\n" .
"STEPS TO FIX:\n" .
"1. Move the file: mv $file " . base_path('app/RSpade/Commands/Rsx/') . "\n" .
"2. Update the namespace to include \\Rsx\n" .
"3. Run 'composer dump-autoload' to update class mappings",
'high'
);
}
// Check for migrate: commands
if (preg_match('/\$signature\s*=\s*[\'"]migrate:/i', $content)) {
$this->add_violation(
$file,
0,
"Migration command found in root Commands directory",
"File: $filename contains 'migrate:' command signature",
"COMMAND ORGANIZATION VIOLATION\n\n" .
"This command has signature starting with 'migrate:' but is not in the Migrate subdirectory.\n\n" .
"REQUIRED ACTION:\n" .
"Move this file to app/Console/Commands/Migrate/ or one of its subdirectories.\n\n" .
"WHY THIS MATTERS:\n" .
"- All migration-related commands must be organized in the Migrate subdirectory\n" .
"- This groups database migration tools together\n" .
"- Maintains consistent command organization\n" .
"- Makes it easier to find migration-related commands\n\n" .
"STEPS TO FIX:\n" .
"1. Move the file: mv $file " . $commands_dir . "/Migrate/\n" .
"2. Update the namespace to include \\Migrate\n" .
"3. Run 'composer dump-autoload' to update class mappings",
'high'
);
}
}
}
/**
* Standard check method - not used for this rule
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule uses check_commands instead
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class DuplicateCaseFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
private static bool $checked = false;
public function get_id(): string
{
return 'FILE-CASE-DUP-01';
}
public function get_name(): string
{
return 'Duplicate Files with Different Case';
}
public function get_description(): string
{
return 'Detects files with same name but different case - breaks Windows/macOS compatibility';
}
public function get_file_patterns(): array
{
return ['*']; // Check all files
}
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
* duplicate case files break Windows/macOS compatibility and must be caught immediately.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Check for duplicate files with different case
* This checks the entire manifest once rather than per-file
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only run this check once for the entire manifest
if (self::$checked) {
return;
}
self::$checked = true;
// Get all files from the manifest
$all_files = Manifest::get_all();
// Build map of directories to files
$files_by_dir = [];
foreach ($all_files as $file => $file_metadata) {
// Skip vendor and node_modules
if (str_contains($file, '/vendor/') || str_contains($file, '/node_modules/')) {
continue;
}
$dir = dirname($file);
$filename = basename($file);
if (!isset($files_by_dir[$dir])) {
$files_by_dir[$dir] = [];
}
$files_by_dir[$dir][] = $filename;
}
// Check each directory for case-insensitive duplicates
foreach ($files_by_dir as $dir => $filenames) {
$seen_lowercase = [];
foreach ($filenames as $filename) {
$filename_lower = strtolower($filename);
if (isset($seen_lowercase[$filename_lower])) {
$existing = $seen_lowercase[$filename_lower];
// Only report if actual case is different
if ($existing !== $filename) {
$file1 = $dir . '/' . $existing;
$file2 = $dir . '/' . $filename;
// Count uppercase characters to determine which file to favor
$uppercase_count1 = preg_match_all('/[A-Z]/', $existing);
$uppercase_count2 = preg_match_all('/[A-Z]/', $filename);
// Favor the file with more uppercase characters
$preferred_file = ($uppercase_count2 > $uppercase_count1) ? $file2 : $file1;
$error_message = "Code Quality Violation (FILE-CASE-DUP-01) - Duplicate files with different case\n\n";
$error_message .= "CRITICAL: This BREAKS Windows/macOS compatibility!\n\n";
$error_message .= "Directory: {$dir}\n";
$error_message .= "File 1: {$existing}\n";
$error_message .= "File 2: {$filename}\n\n";
$error_message .= "Resolution:\n";
$error_message .= "1. Run: diff -u '{$file1}' '{$file2}' to compare\n";
$error_message .= "2. Determine which file has the correct functionality\n";
$error_message .= "3. Remove the incorrect/older file\n";
$error_message .= "4. Update all references to use the correct filename\n";
$error_message .= "5. Test thoroughly - IDE may have been using wrong file!";
// Throw immediately on first duplicate found
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($preferred_file),
1
);
}
} else {
$seen_lowercase[$filename_lower] = $filename;
}
}
}
// Reset for next manifest build
self::$checked = false;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class FilenameCase_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-CASE-01';
}
public function get_name(): string
{
return 'Filename Case Check';
}
public function get_description(): string
{
return 'All files in rsx/ should be lowercase';
}
public function get_file_patterns(): array
{
return ['*.*']; // All files
}
public function get_default_severity(): string
{
return 'low';
}
/**
* Check if a filename contains uppercase characters (for RSX files) - from line 1706
* Excludes vendor and resource directories, and .md files
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and resource directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/resource/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check files in rsx/ directory
if (!str_contains($file_path, '/rsx/')) {
return;
}
// Get just the filename without the directory path
$filename = basename($file_path);
// Skip .md files
if (str_ends_with($filename, '.md')) {
return;
}
// Check if filename contains uppercase characters
if (preg_match('/[A-Z]/', $filename)) {
// Convert to lowercase for suggestion
$suggested = strtolower($filename);
$this->add_violation(
$file_path,
0,
"Filename '{$filename}' contains uppercase characters. All files in rsx/ should be lowercase.",
$filename,
"Rename file to '{$suggested}'. Remember: class names should still use First_Letter_Uppercase format.",
'low'
);
}
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class FilenameEnhanced_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-ENHANCED-01';
}
public function get_name(): string
{
return 'Enhanced Filename Check';
}
public function get_description(): string
{
return "Detects 'enhanced' in filenames which indicates parallel implementation - a critical code smell";
}
public function get_file_patterns(): array
{
return ['*.*']; // All files
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check if a filename contains 'enhanced' which indicates parallel implementation (from line 1741)
* This is a critical code smell indicating technical debt
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get just the filename without the directory path
$filename = basename($file_path);
// Check if filename contains 'enhanced' (case insensitive)
if (stripos($filename, 'enhanced') !== false) {
// Check if file has exemption marker
$content = file_get_contents($file_path);
if (str_contains($content, '//@enhanced_filename_allowed')) {
return; // File is explicitly exempted
}
// Extract the base name without 'enhanced' for suggestion
$suggested = preg_replace('/[_\-]?enhanced[_\-]?/i', '', $filename);
$resolution = "CRITICAL: Filename contains 'enhanced' which indicates parallel implementation of existing functionality.\n\n";
$resolution .= "INVESTIGATION REQUIRED - Follow this procedure thoroughly (may take several hours):\n\n";
$resolution .= "1. IDENTIFY THE RELATIONSHIP:\n";
$resolution .= " - Search for similarly named files without 'enhanced' (e.g., if this is 'UserEnhanced.php', look for 'User.php')\n";
$resolution .= " - Use grep/search to find: '{$suggested}' and variations\n";
$resolution .= " - Check git history to understand when and why the 'enhanced' version was created\n\n";
$resolution .= "2. ANALYZE THE INVOCATION:\n";
$resolution .= " - Search the codebase for references to '{$filename}' to see how it's used\n";
$resolution .= " - Look for conditional logic, switches, or configuration that chooses between versions\n";
$resolution .= " - Identify any fallback patterns where one version is tried before another\n\n";
$resolution .= "3. COMPARE FUNCTIONALITY:\n";
$resolution .= " - Diff the enhanced file against the original (if found)\n";
$resolution .= " - Identify what improvements were made in the enhanced version\n";
$resolution .= " - Check if the original has any functionality missing from enhanced\n";
$resolution .= " - Document the differences and determine completeness of enhanced version\n\n";
$resolution .= "4. DETERMINE MIGRATION PATH:\n";
$resolution .= " If enhanced version is a complete replacement:\n";
$resolution .= " - Verify enhanced version has ALL necessary functionality from original\n";
$resolution .= " - Remove the old/original file\n";
$resolution .= " - Rename enhanced file to the original name (e.g., 'UserEnhanced.php' → 'User.php')\n";
$resolution .= " - Update all references to use the single, renamed file\n";
$resolution .= " - Remove any conditional/switching/fallback code\n\n";
$resolution .= " If versions serve different purposes:\n";
$resolution .= " - Rename the enhanced file to better describe its specific purpose\n";
$resolution .= " - Update documentation to clarify the distinct roles\n\n";
$resolution .= "5. IF UNCLEAR:\n";
$resolution .= " Present findings to the user including:\n";
$resolution .= " - List of similar files found\n";
$resolution .= " - How each version is invoked\n";
$resolution .= " - Key differences between versions\n";
$resolution .= " - Request guidance on consolidation strategy\n\n";
$resolution .= "IMPORTANT: The goal is to eliminate dual implementations. Having both 'document_parser.php' and 'enhanced_document_parser.php' creates:\n";
$resolution .= "- Confusion about which to use\n";
$resolution .= "- Maintenance burden keeping both in sync\n";
$resolution .= "- Potential bugs from inconsistent behavior\n";
$resolution .= "- Technical debt that compounds over time\n\n";
$resolution .= "To mark legitimate use (extremely rare): Add '//@enhanced_filename_allowed' comment to the file.\n";
$resolution .= "This should only be done when 'enhanced' is genuinely part of the domain language, not indicating an upgrade.";
$this->add_violation(
$file_path,
0,
"CRITICAL: Filename contains 'enhanced' which indicates parallel implementation of existing functionality.",
$filename,
$resolution,
'critical'
);
}
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class FilenameSpaces_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-SPACE-01';
}
public function get_name(): string
{
return 'Filename Spaces Check';
}
public function get_description(): string
{
return 'Filenames and directory paths must not contain spaces';
}
public function get_file_patterns(): array
{
// Return multiple common patterns to match all files
// The checker uses these with matches_pattern which does simple extension checking
return ['*.php', '*.js', '*.css', '*.scss', '*.blade.php', '*.json', '*.xml', '*.md', '*.txt', '*.yml', '*.yaml', '*.sql', '*.sh', '*.jqhtml', '*.ts', '*.tsx', '*.jsx', '*'];
}
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
* spaces in filenames break shell commands and framework operations.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update - throws immediately on violation
* This is more efficient than checking later
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return null;
}
// Get relative path from absolute
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Check for spaces in the entire path
if (str_contains($relative_path, ' ')) {
// Get just the filename
$filename = basename($relative_path);
$dirname = dirname($relative_path);
// Build error message
if (str_contains($filename, ' ')) {
$suggested = str_replace(' ', '_', $filename);
$error_message = "Code Quality Violation (FILE-SPACE-01) - Filename '{$filename}' contains spaces\n\n";
$error_message .= "File: {$relative_path}\n\n";
$error_message .= "Spaces in filenames break shell commands, URLs, and tooling.\n\n";
$error_message .= "Resolution:\nRename file to '{$suggested}' (replace spaces with underscores or remove them).";
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
1
);
}
if (str_contains($dirname, ' ')) {
// Find which directory has the space
$path_parts = explode('/', $dirname);
$problematic_dirs = array_filter($path_parts, fn($part) => str_contains($part, ' '));
$problematic_str = implode(', ', $problematic_dirs);
$error_message = "Code Quality Violation (FILE-SPACE-01) - Directory path contains spaces\n\n";
$error_message .= "File: {$relative_path}\n\n";
$error_message .= "Directories with spaces: {$problematic_str}\n\n";
$error_message .= "Resolution:\nRename directories to remove spaces. This is critical as spaces in paths break shell commands, git operations, and various build tools.";
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
1
);
}
}
// No metadata needed - we throw on violation
return null;
}
/**
* Check if a filename or any directory in its path contains spaces
* This method is now just a fallback - on_manifest_file_update handles the real work
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Check for spaces in the entire path
if (str_contains($file_path, ' ')) {
// Get just the filename
$filename = basename($file_path);
$dirname = dirname($file_path);
// Determine if the space is in the filename or directory
if (str_contains($filename, ' ')) {
$suggested = str_replace(' ', '_', $filename);
$this->add_violation(
$file_path,
0,
"Filename '{$filename}' contains spaces which will cause issues with shell commands, URLs, and tooling.",
$filename,
"Rename file to '{$suggested}' (replace spaces with underscores or remove them).",
'critical'
);
}
if (str_contains($dirname, ' ')) {
// Find which directory has the space
$path_parts = explode('/', $dirname);
$problematic_dirs = array_filter($path_parts, fn($part) => str_contains($part, ' '));
$problematic_str = implode(', ', $problematic_dirs);
$this->add_violation(
$file_path,
0,
"Directory path contains spaces in: {$problematic_str}",
$dirname,
"Rename directories to remove spaces. This is critical as spaces in paths break shell commands, git operations, and various build tools.",
'critical'
);
}
}
}
}

View File

@@ -0,0 +1,358 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*
* @ROUTE-EXISTS-01-EXCEPTION - This file generates code templates with placeholder route names
*/
namespace App\RSpade\CodeQuality\Rules\Common;
use Illuminate\Support\Facades\Route;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Dispatch\Dispatcher;
use App\RSpade\Core\Manifest\Manifest;
/**
* HardcodedInternalUrlRule - Detect hardcoded internal URLs in href attributes
*
* This rule scans .blade.php and .jqhtml files for href attributes containing
* hardcoded internal routes (URLs starting with "/" without file extensions)
* and suggests using the proper route generation methods instead.
*/
class HardcodedInternalUrl_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique identifier for this rule
*
* @return string
*/
public function get_id(): string
{
return 'URL-HARDCODE-01';
}
/**
* Get the default severity level
*
* @return string One of: critical, high, medium, low, convention
*/
public function get_default_severity(): string
{
return 'medium';
}
/**
* Get the file patterns this rule applies to
*
* @return array
*/
public function get_file_patterns(): array
{
return ['*.blade.php', '*.jqhtml'];
}
/**
* Get the display name for this rule
*
* @return string
*/
public function get_name(): string
{
return 'Hardcoded Internal URL Detection';
}
/**
* Get the description of what this rule checks
*
* @return string
*/
public function get_description(): string
{
return 'Detects hardcoded internal URLs in href attributes and suggests using route generation methods';
}
/**
* Check the file contents for violations
*
* @param string $file_path The path to the file being checked
* @param string $contents The contents of the file
* @param array $metadata Additional metadata about the file
* @return void
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Initialize manifest to ensure routes are available
try {
Manifest::init();
} catch (\Exception $e) {
// If manifest fails to initialize, we can't check routes
return;
}
$is_jqhtml = str_ends_with($file_path, '.jqhtml');
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
// Find all href attributes in the line
// Match href="..." or href='...'
if (preg_match_all('/href\s*=\s*["\']([^"\']+)["\']/', $line, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[1] as $match) {
$url = $match[0];
$position = $match[1];
// Check if this is a likely internal route
if (!$this->_is_likely_internal_route($url)) {
continue;
}
// Extract base URL and query params
$url_parts = parse_url($url);
$base_url = $url_parts['path'] ?? '/';
$query_string = $url_parts['query'] ?? '';
// Try to resolve the URL to a route
$route_info = null;
try {
$route_info = Dispatcher::resolve_url_to_route($base_url, 'GET');
} catch (\Exception $e) {
// URL doesn't resolve to a known route
continue;
}
$suggested_code = '';
if ($route_info) {
// Found RSX route
$controller_class = $route_info['class'] ?? '';
$method_name = $route_info['method'] ?? '';
$route_params = $route_info['params'] ?? [];
// Parse query string params
$query_params = [];
if ($query_string) {
parse_str($query_string, $query_params);
}
// Merge all params (route params take precedence)
$all_params = array_merge($query_params, $route_params);
// Extract just the class name without namespace
$class_parts = explode('\\', $controller_class);
$class_name = end($class_parts);
// Generate the suggested replacement code
$suggested_code = $this->_generate_suggested_code(
$class_name,
$method_name,
$all_params,
$is_jqhtml
);
} else {
// Check if it's a Laravel route
$laravel_route = $this->_find_laravel_route($base_url);
if ($laravel_route) {
$suggested_code = $this->_generate_laravel_suggestion($laravel_route, $query_string, $is_jqhtml);
} else {
// No route found, skip
continue;
}
}
// Add violation
$this->add_violation(
$line_num + 1,
$position,
"Hardcoded internal URL detected: {$url}",
$line,
"Use route generation instead:\n{$suggested_code}"
);
}
}
}
}
/**
* Check if a URL is likely an internal route
*
* @param string $url
* @return bool
*/
protected function _is_likely_internal_route(string $url): bool
{
// Must start with /
if (!str_starts_with($url, '/')) {
return false;
}
// Skip absolute URLs (with protocol)
if (preg_match('#^//#', $url)) {
return false;
}
// Extract path before query string
$path = strtok($url, '?');
// Get the last segment of the path
$segments = explode('/', trim($path, '/'));
$last_segment = end($segments);
// If last segment has a dot (file extension), it's likely a file not a route
if ($last_segment && str_contains($last_segment, '.')) {
return false;
}
// Skip common static asset paths
$static_prefixes = ['/assets/', '/css/', '/js/', '/images/', '/img/', '/fonts/', '/storage/'];
foreach ($static_prefixes as $prefix) {
if (str_starts_with($path, $prefix)) {
return false;
}
}
return true;
}
/**
* Generate suggested replacement code
*
* @param string $class_name
* @param string $method_name
* @param array $params
* @param bool $is_jqhtml
* @return string
*/
protected function _generate_suggested_code(string $class_name, string $method_name, array $params, bool $is_jqhtml): string
{
if ($is_jqhtml) {
// JavaScript version for .jqhtml files using <%= %> syntax
if (empty($params)) {
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url() %>";
} else {
$params_json = json_encode($params, JSON_UNESCAPED_SLASHES);
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url({$params_json}) %>";
}
} else {
// PHP version for .blade.php files
if (empty($params)) {
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url() }}";
} else {
$params_str = $this->_format_php_array($params);
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url({$params_str}) }}";
}
}
}
/**
* Format a PHP array for display
*
* @param array $params
* @return string
*/
protected function _format_php_array(array $params): string
{
$items = [];
foreach ($params as $key => $value) {
$key_str = var_export($key, true);
$value_str = var_export($value, true);
$items[] = "{$key_str} => {$value_str}";
}
return '[' . implode(', ', $items) . ']';
}
/**
* Find Laravel route by URL
*
* @param string $url
* @return string|null Route name if found
*/
protected function _find_laravel_route(string $url): ?string
{
// Get all Laravel routes
$routes = Route::getRoutes();
foreach ($routes as $route) {
// Check if URL matches this route's URI
if ($route->uri() === ltrim($url, '/')) {
// Get the route name if it has one
$name = $route->getName();
if ($name) {
return $name;
}
// No name, but route exists - return the URI for direct use
return $url;
}
}
return null;
}
/**
* Generate Laravel route suggestion
*
* @param string $route_name
* @param string $query_string
* @param bool $is_jqhtml
* @return string
*/
protected function _generate_laravel_suggestion(string $route_name, string $query_string, bool $is_jqhtml): string
{
// If route_name starts with /, it means no named route exists
if (str_starts_with($route_name, '/')) {
// Suggest adding a name to the route
$suggested_name = $this->_suggest_route_name($route_name);
if ($is_jqhtml) {
return "<%= '{$route_name}' %> <!-- Add name to route: ->name('{$suggested_name}'), then use route('{$suggested_name}') -->";
} else {
return "{{ route('{$suggested_name}') }}\n// First add ->name('{$suggested_name}') to the route definition in routes/web.php";
}
}
// Route has a name, use it
if ($is_jqhtml) {
// JavaScript version for .jqhtml files
if ($query_string) {
$query_params = [];
parse_str($query_string, $query_params);
$params_json = json_encode($query_params, JSON_UNESCAPED_SLASHES);
// Note: jqhtml would need a custom helper for Laravel routes
return "<%= route('{$route_name}', {$params_json}) %> <!-- Requires custom route() helper -->";
} else {
return "<%= route('{$route_name}') %> <!-- Requires custom route() helper -->";
}
} else {
// PHP version for .blade.php files
if ($query_string) {
$query_params = [];
parse_str($query_string, $query_params);
$params_str = $this->_format_php_array($query_params);
return "{{ route('{$route_name}', {$params_str}) }}";
} else {
return "{{ route('{$route_name}') }}";
}
}
}
/**
* Suggest a route name based on the URL path
*
* @param string $url
* @return string
*/
protected function _suggest_route_name(string $url): string
{
// Remove leading slash and convert to dot notation
$path = ltrim($url, '/');
// Convert path segments to route name
// /test-bundle-facade => test.bundle.facade
// /_idehelper => idehelper
$path = str_replace('_', '', $path); // Remove leading underscores
$path = str_replace('-', '.', $path); // Convert dashes to dots
$path = str_replace('/', '.', $path); // Convert slashes to dots
return $path ?: 'home';
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Ensures SCSS manifest module has lower priority than modules it depends on
*
* The SCSS module needs to run after Blade, JavaScript, and Jqhtml modules
* because it checks their output to determine if SCSS files should have an ID
* based on matching class selectors.
*/
class ManifestModulePriority_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MANIFEST-PRIORITY-01';
}
public function get_name(): string
{
return 'Manifest Module Priority Order';
}
public function get_description(): string
{
return 'Ensures SCSS manifest module runs after modules it depends on';
}
public function get_file_patterns(): array
{
// We'll check specific module files
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check the specific manifest module files
$module_files = [
'app/RSpade/Modules/Scss_ManifestModule.php',
'app/RSpade/Modules/Blade_ManifestModule.php',
'app/RSpade/Modules/JavaScript_ManifestModule.php',
'app/RSpade/Integrations/Jqhtml/Jqhtml_ManifestModule.php'
];
$is_module_file = false;
foreach ($module_files as $module_file) {
if (str_ends_with($file_path, $module_file)) {
$is_module_file = true;
break;
}
}
if (!$is_module_file) {
return;
}
// Extract priority from this file
$current_priority = $this->extract_priority($contents);
if ($current_priority === null) {
return; // Can't find priority method
}
// If this is the SCSS module, check all others
if (str_ends_with($file_path, 'Scss_ManifestModule.php')) {
$this->check_scss_priority($file_path, $current_priority);
}
}
/**
* Extract the priority value from module contents
*/
private function extract_priority(string $contents): ?int
{
// Look for: public function priority(): int { return NUMBER; }
if (preg_match('/public\s+function\s+priority\s*\(\s*\)\s*:\s*int\s*\{[^}]*return\s+(\d+)\s*;/s', $contents, $matches)) {
return (int)$matches[1];
}
return null;
}
/**
* Check that SCSS priority is lower (higher number) than all dependencies
*/
private function check_scss_priority(string $scss_file_path, int $scss_priority): void
{
$base_path = base_path();
$dependencies = [
'Blade_ManifestModule' => $base_path . '/app/RSpade/Modules/Blade_ManifestModule.php',
'JavaScript_ManifestModule' => $base_path . '/app/RSpade/Modules/JavaScript_ManifestModule.php',
'Jqhtml_ManifestModule' => $base_path . '/app/RSpade/Integrations/Jqhtml/Jqhtml_ManifestModule.php'
];
$violations = [];
foreach ($dependencies as $name => $path) {
if (!file_exists($path)) {
continue; // Module might not exist (e.g., Jqhtml is optional)
}
$contents = file_get_contents($path);
$priority = $this->extract_priority($contents);
if ($priority === null) {
continue; // Can't find priority
}
// SCSS priority should be higher number (lower priority) than dependencies
if ($scss_priority <= $priority) {
$violations[] = " - {$name}: priority={$priority} (SCSS has {$scss_priority})";
}
}
if (!empty($violations)) {
$violation_list = implode("\n", $violations);
$this->add_violation(
$scss_file_path,
0,
"SCSS manifest module priority must be lower than modules it depends on",
null,
"The SCSS manifest module depends on output from Blade, JavaScript, and Jqhtml modules\n" .
"to determine which SCSS files should have an ID based on matching class selectors.\n\n" .
"Priority violations found:\n" .
$violation_list . "\n\n" .
"Fix: Change Scss_ManifestModule priority() to return a value higher than all dependencies.\n" .
"Remember: Higher number = lower priority (runs later).\n\n" .
"Example: If Blade=15, JavaScript=20, Jqhtml=25, then SCSS should be >25 (e.g., 100).",
'critical'
);
}
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Rule to detect .old. files that should not be committed
* Only runs during pre-commit checks
*/
class OldFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-OLD-01';
}
public function get_name(): string
{
return 'Old Files Detection';
}
public function get_description(): string
{
return 'Detects .old files that should not be committed';
}
public function get_file_patterns(): array
{
// Check all files
return ['*'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Only run this rule during pre-commit tests
*/
public function should_run(array $options = []): bool
{
return isset($options['pre-commit-tests']) && $options['pre-commit-tests'] === true;
}
/**
* Check for .old. or .*.old file naming patterns
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip directories outside our scan paths
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
$basename = basename($file_path);
// Check for .old. pattern (e.g., something.old.php)
if (preg_match('/\\.old\\.\\w+$/', $basename)) {
$this->add_violation(
$file_path,
1,
"File uses forbidden .old.(extension) naming pattern",
$basename,
"The .old.(extension) pattern is NOT ALLOWED. Files named like 'file.old.php' " .
"are still treated as .php files and included in bundles as live code.\n\n" .
"SOLUTIONS:\n" .
"1. Rename to '.php.old' or '.js.old' (extension at the end)\n" .
"2. Move to /archived/ directory outside scan paths\n" .
"3. Delete the file if no longer needed",
'critical'
);
}
// Check for .*.old pattern (e.g., something.php.old)
if (preg_match('/\\.\\w+\\.old$/', $basename)) {
$this->add_violation(
$file_path,
1,
"Old file detected - should not be committed",
$basename,
"Files ending in .old should not be committed to the repository.\n\n" .
"SOLUTIONS:\n" .
"1. Move to /archived/ directory outside scan paths\n" .
"2. Delete the file if no longer needed\n" .
"3. Use proper version control (git) to track file history",
'high'
);
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class PackageJson_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PKG-JSON-01';
}
public function get_name(): string
{
return 'Package.json devDependencies Check';
}
public function get_description(): string
{
return 'Ensures package.json files only use dependencies, not devDependencies';
}
public function get_file_patterns(): array
{
return ['package.json'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check package.json files for devDependencies
* RSpade standard: All packages should be in dependencies, not devDependencies
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check package.json files
if (basename($file_path) !== 'package.json') {
return;
}
// Skip node_modules
if (str_contains($file_path, '/node_modules/')) {
return;
}
// Parse JSON
$json = json_decode($contents, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// Invalid JSON - skip check (other rules will catch this)
return;
}
// Check for devDependencies
if (isset($json['devDependencies']) && !empty($json['devDependencies'])) {
$dev_count = count($json['devDependencies']);
$packages = array_keys($json['devDependencies']);
$packages_list = implode(', ', array_slice($packages, 0, 5));
if ($dev_count > 5) {
$packages_list .= ', and ' . ($dev_count - 5) . ' more';
}
$this->add_violation(
$file_path,
0, // JSON files don't have meaningful line numbers for this check
"RSpade Standard Violation: package.json contains {$dev_count} devDependencies. " .
"In RSpade, all packages should be in 'dependencies' to ensure consistent installations. " .
"Found packages: {$packages_list}",
'"devDependencies": { ... }',
"Move all packages from 'devDependencies' to 'dependencies' and remove the 'devDependencies' key entirely. " .
"RSpade makes no distinction between dev and production packages - all software needed for the project should be installed.",
'high'
);
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* RedundantIndexActionRule - Checks for unnecessary 'index' action in Route calls
*
* This rule detects when Rsx::Route() or Rsx.Route() is called with 'index' as the
* second parameter, which is redundant since 'index' is the default value.
*
* Example violations:
* - Rsx::Route('Controller') // PHP
* - Rsx.Route('Controller', 'index') // JavaScript
*
* Correct usage:
* - Rsx::Route('Controller') // PHP
* - Rsx.Route('Controller') // JavaScript
*/
class RedundantIndexAction_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'ROUTE-INDEX-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Redundant index Action in Route';
}
/**
* Get rule description
*/
public function get_description(): string
{
return "Detects unnecessary 'index' as second parameter in Route calls since it's the default";
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.blade.php', '*.jqhtml'];
}
/**
* Whether this rule is called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Only run during rsx:check
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'low'; // This is a style/convention issue, not a functional problem
}
/**
* Check a file for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if exception comment is present
if (strpos($contents, '@ROUTE-INDEX-01-EXCEPTION') !== false) {
return;
}
// Pattern to match Rsx::Route() or Rsx.Route() calls with 'index' as second parameter
// Matches both single and double quotes
$pattern = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"],\s*[\'"]index[\'"]\s*\)/';
if (preg_match_all($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
$controller = $matches[1][$index][0];
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Build remediation message
$is_php = str_ends_with($file_path, '.php') || str_ends_with($file_path, '.blade.php');
$operator = $is_php ? '::' : '.';
$correct_usage = "Rsx{$operator}Route('{$controller}')";
$remediation = "The 'index' action is the default value and should be omitted.\n\n";
$remediation .= "CURRENT (redundant):\n";
$remediation .= " {$full_match}\n\n";
$remediation .= "CORRECTED (cleaner):\n";
$remediation .= " {$correct_usage}\n\n";
$remediation .= "CONVENTION:\n";
$remediation .= "The second parameter of Rsx{$operator}Route() defaults to 'index'.\n";
$remediation .= "Only specify the action when it's NOT 'index'.\n\n";
$remediation .= "EXAMPLES:\n";
$remediation .= " Rsx{$operator}Route('User_Controller') // Goes to index action\n";
$remediation .= " Rsx{$operator}Route('User_Controller', 'edit') // Goes to edit action\n";
$remediation .= " Rsx{$operator}Route('User_Controller', 'show') // Goes to show action";
$this->add_violation(
$file_path,
$line_number,
"Redundant 'index' action in Route call",
$code_snippet,
$remediation,
'low'
);
}
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Rule to detect incorrect 'resources' directory naming
* Should be 'resource' (singular) not 'resources' (plural)
*/
class ResourceDirectory_CodeQualityRule extends CodeQualityRule_Abstract
{
protected static $checked_directories = [];
public function get_id(): string
{
return 'DIR-RESOURCE-01';
}
public function get_name(): string
{
return 'Resource Directory Naming';
}
public function get_description(): string
{
return 'Enforces singular "resource" directory naming convention';
}
public function get_file_patterns(): array
{
// Check all files to extract directory paths
return ['*'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for 'resources' directory in file path
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip directories outside our scan paths
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip if already inside a 'resource' directory (contents are invisible to framework)
if (preg_match('#/resource/#', $file_path)) {
return;
}
// Check if path contains 'resources' directory
if (preg_match('#/resources/#', $file_path, $matches, PREG_OFFSET_CAPTURE)) {
// Extract the directory path up to and including 'resources'
$offset = $matches[0][1];
$dir_path = substr($file_path, 0, $offset + strlen('/resources'));
// Only report once per directory
if (isset(static::$checked_directories[$dir_path])) {
return;
}
static::$checked_directories[$dir_path] = true;
$this->add_violation(
$file_path,
1,
"Directory named 'resources' (plural) detected - should be 'resource' (singular)",
"Directory: {$dir_path}/",
"The directory name 'resources' is not allowed in RSX.\n\n" .
"USE 'resource' INSTEAD (singular, not plural).\n\n" .
"WHY THIS MATTERS:\n" .
"'resource' is a special directory that is IGNORED by:\n" .
"- The RSpade manifest system\n" .
"- The autoloader\n" .
"- Bundle generation\n" .
"- All Manifest.php functions\n\n" .
"PURPOSE OF resource/ DIRECTORY:\n" .
"Store special-purpose files that are referenced but not executed:\n" .
"- Raw source code (e.g., Bootstrap 5 source)\n" .
"- Supplemental utilities (e.g., Node.js applications)\n" .
"- Documentation files\n" .
"- Assets that should not be bundled\n\n" .
"ACTION REQUIRED:\n" .
"Rename the directory from 'resources' to 'resource'",
'high'
);
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class RootFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-ROOT-01';
}
public function get_name(): string
{
return 'Root Files Check';
}
public function get_description(): string
{
return 'Check for unauthorized files in project root - only whitelisted build configuration files should be in root';
}
public function get_file_patterns(): array
{
// This rule works differently - it checks root directory, not individual files
return [];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Check for unauthorized PHP/JS files in project root (from line 1602)
* Only whitelisted build configuration files should be in root
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule needs special handling - it should be called once, not per file
// We'll handle this in the check_root() method instead
}
/**
* Special method to check root files - called once per run
*/
public function check_root(): void
{
$project_root = function_exists('base_path') ? base_path() : '/var/www/html';
$whitelist = function_exists('config') ? config('rsx.code_quality.root_whitelist', []) : [];
// Get all PHP and JS files in root (not subdirectories)
$files = glob($project_root . '/*.{php,js}', GLOB_BRACE);
foreach ($files as $file_path) {
$filename = basename($file_path);
// Skip if whitelisted
if (in_array($filename, $whitelist)) {
continue;
}
$this->add_violation(
$file_path,
0,
"Unauthorized file '{$filename}' found in project root. Only whitelisted build configuration files should exist in the root directory.",
null,
"This file appears to be a one-off test script that should be removed before commit. " .
"LLM agents often create test files in the root directory for testing specific features. " .
"These should be removed or moved to proper test directories. " .
"If this file is legitimately needed in the root, add '{$filename}' to the whitelist in config/rsx.php under 'code_quality.root_whitelist'.",
'medium'
);
}
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* RouteExistsRule - Validates that Rsx::Route() calls reference existing routes
*
* This rule checks both PHP and JavaScript files for Route() calls with literal
* string parameters and validates that the referenced controller and method
* combination actually exists as a route in the manifest.
*
* Example violations:
* - Rsx::Route('NonExistent_Controller')
* - Route('Some_Controller', 'missing_method')
*
* The rule only checks when both parameters are string literals, not variables.
*/
class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'ROUTE-EXISTS-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Route Target Exists Validation';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Validates that Rsx::Route() calls reference controller methods that actually exist as routes';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.blade.php'];
}
/**
* Whether this rule is called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Only run during rsx:check, not during manifest build
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'high';
}
/**
* Check if a route exists using the same logic as Rsx::Route()
*/
private function route_exists(string $controller, string $method): bool
{
try {
// Use the same validation logic as Rsx::Route()
// If this doesn't throw an exception, the route exists
\App\RSpade\Core\Rsx::Route($controller, $method);
return true;
} catch (\Exception $e) {
return false;
}
}
/**
* Check a file for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if exception comment is present
if (strpos($contents, '@ROUTE-EXISTS-01-EXCEPTION') !== false) {
return;
}
// Pattern to match Rsx::Route and Rsx.Route calls (NOT plain Route())
// Matches both single and double parameter versions:
// - Rsx::Route('Controller') // PHP, defaults to 'index'
// - Rsx::Route('Controller', 'method') // PHP
// - Rsx.Route('Controller') // JavaScript, defaults to 'index'
// - Rsx.Route('Controller', 'method') // JavaScript
// Pattern for two parameters
$pattern_two_params = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/';
// Pattern for single parameter (defaults to 'index')
$pattern_one_param = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/';
// First check two-parameter calls
if (preg_match_all($pattern_two_params, $contents, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
$controller = $matches[1][$index][0];
$method = $matches[2][$index][0];
// Skip if contains template variables like {$variable}
if (str_contains($controller, '{$') || str_contains($controller, '${') ||
str_contains($method, '{$') || str_contains($method, '${')) {
continue;
}
// Skip if method starts with '#' - indicates unimplemented route
if (str_starts_with($method, '#')) {
continue;
}
// Skip if this route exists
if ($this->route_exists($controller, $method)) {
continue;
}
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Build suggestion
$suggestion = $this->build_suggestion($controller, $method);
$this->add_violation(
$file_path,
$line_number,
"Route target does not exist: {$controller}::{$method}",
$code_snippet,
$suggestion,
'high'
);
}
}
// Then check single-parameter calls (avoiding overlap with two-parameter calls)
if (preg_match_all($pattern_one_param, $contents, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
// Skip if this is actually a two-parameter call (has a comma after the first param)
$after_match_pos = $offset + strlen($full_match);
$chars_after = substr($contents, $after_match_pos, 10);
if (preg_match('/^\s*,/', $chars_after)) {
continue; // This is a two-parameter call, already handled above
}
$controller = $matches[1][$index][0];
$method = 'index'; // Default to 'index'
// Skip if contains template variables like {$variable}
if (str_contains($controller, '{$') || str_contains($controller, '${')) {
continue;
}
// Skip if this route exists
if ($this->route_exists($controller, $method)) {
continue;
}
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Build suggestion
$suggestion = $this->build_suggestion($controller, $method);
$this->add_violation(
$file_path,
$line_number,
"Route target does not exist: {$controller}::{$method}",
$code_snippet,
$suggestion,
'high'
);
}
}
}
/**
* Build suggestion for fixing the violation
*/
private function build_suggestion(string $controller, string $method): string
{
$suggestions = [];
// Simple suggestion since we're using the same validation as Rsx::Route()
$suggestions[] = "Route target does not exist: {$controller}::{$method}";
$suggestions[] = "\nTo fix this issue:";
$suggestions[] = "1. Correct the controller/method names if they're typos";
$suggestions[] = "2. Implement the missing route if it's a new feature:";
$suggestions[] = " - Create the controller if it doesn't exist";
$suggestions[] = " - Add the method with a #[Route] attribute";
$suggestions[] = "3. Use '#' prefix for unimplemented routes (recommended):";
$suggestions[] = " - Use Rsx::Route('Controller', '#index') for unimplemented index methods";
$suggestions[] = " - Use Rsx::Route('Controller', '#method_name') for other unimplemented methods";
$suggestions[] = " - Routes with '#' prefix will generate '#' URLs and bypass this validation";
$suggestions[] = " - Example: Rsx::Route('Backend_Users_Controller', '#index')";
return implode("\n", $suggestions);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
/**
* Check that Route attributes don't use invalid {param} syntax
* RSX uses :param syntax instead of Laravel's {param} syntax
*/
class RouteSyntax_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'ROUTE-SYNTAX-01';
}
public function get_name(): string
{
return 'Route Pattern Syntax Check';
}
public function get_description(): string
{
return 'Ensures route patterns use :param syntax instead of {param}';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Run during manifest build for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip files without public static methods
if (!isset($metadata['public_static_methods']) || !is_array($metadata['public_static_methods'])) {
return;
}
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
if (!isset($method_data['attributes']) || !is_array($method_data['attributes'])) {
continue;
}
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
// Check for Route attribute
if ($attr_name !== 'Route' && !str_ends_with($attr_name, '\\Route')) {
continue;
}
foreach ($attr_instances as $attr_args) {
// Check first argument (the route pattern)
if (!isset($attr_args[0])) {
continue;
}
$pattern = $attr_args[0];
// Check if pattern contains { or }
if (strpos($pattern, '{') !== false || strpos($pattern, '}') !== false) {
$this->throw_invalid_route_syntax($file_path, $method_name, $pattern);
}
}
}
}
}
private function throw_invalid_route_syntax(string $file_path, string $method_name, string $pattern): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Invalid route pattern syntax\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Method: {$method_name}\n";
$error_message .= "Pattern: {$pattern}\n\n";
$error_message .= "RSX routes use :param syntax, not Laravel's {param} syntax.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "Your route pattern contains curly braces { or } which are not supported.\n";
$error_message .= "RSX uses colon-prefixed parameters like :id, :slug, etc.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " #[Route('/users/{id}')]\n";
$error_message .= " #[Route('/posts/{slug}/edit')]\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " #[Route('/users/:id')]\n";
$error_message .= " #[Route('/posts/:slug/edit')]\n\n";
$error_message .= "WHY THIS MATTERS:\n";
$error_message .= "- RSX routing system specifically uses :param syntax\n";
$error_message .= "- The dispatcher expects colon-prefixed parameters\n";
$error_message .= "- Laravel-style {param} patterns won't be recognized\n\n";
$error_message .= "FIX:\n";
$error_message .= "Replace all {param} with :param in your route pattern.\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class RsxCommandsDeprecated_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'RSX-CMD-DEPRECATED-01';
}
public function get_name(): string
{
return 'RSX Commands Deprecated Features Check';
}
public function get_description(): string
{
return 'Checks RSX commands for deprecated features or references';
}
public function get_file_patterns(): array
{
// This rule doesn't use standard file pattern matching
// It scans its own directory when check_rsx_commands() is called
return [];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Standard check method - not used for this rule
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule uses check_rsx_commands() instead
return;
}
/**
* Special method to check RSX commands directory
* Called only when rsx:check is run with default paths
*/
public function check_rsx_commands(): void
{
// Only run when using default paths
if (!($this->config['using_default_paths'] ?? false)) {
return;
}
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$commands_dir = $base_path . '/app/RSpade/Commands/Rsx';
// Check if directory exists
if (!is_dir($commands_dir)) {
return;
}
// Scan for PHP files in the RSX commands directory
$files = glob($commands_dir . '/*.php');
foreach ($files as $file_path) {
$this->check_file_for_deprecated($file_path);
}
}
/**
* Check a single file for deprecated references
*/
private function check_file_for_deprecated(string $file_path): void
{
$contents = file_get_contents($file_path);
if ($contents === false) {
return;
}
$lines = explode("\n", $contents);
$filename = basename($file_path);
foreach ($lines as $line_number => $line) {
// Check for the word 'deprecated' (case insensitive)
if (stripos($line, 'deprecated') !== false) {
// Get the actual line number (1-indexed)
$actual_line = $line_number + 1;
// Extract context around the deprecated reference
$context_start = max(0, $line_number - 2);
$context_end = min(count($lines) - 1, $line_number + 2);
$context_lines = array_slice($lines, $context_start, $context_end - $context_start + 1);
$context = implode("\n", $context_lines);
$this->add_violation(
$file_path,
$actual_line,
"Command file '{$filename}' contains reference to 'deprecated'",
trim($line),
$this->get_deprecated_remediation($filename, $line),
'high'
);
}
}
}
/**
* Get remediation message for deprecated references
*/
private function get_deprecated_remediation(string $filename, string $line): string
{
return "DEPRECATED FEATURE REFERENCE IN RSX COMMAND
File: {$filename}
Line containing 'deprecated': {$line}
RSX commands should not contain deprecated features or references to deprecated functionality.
REQUIRED ACTIONS:
1. Remove the deprecated feature or functionality from the command
2. Remove any help text or documentation mentioning deprecated features
3. Update command logic to use current recommended approaches
4. If the entire command is deprecated, consider removing it
COMMON DEPRECATED PATTERNS TO REMOVE:
- Old command aliases marked as deprecated
- Legacy options or flags no longer in use
- References to deprecated framework features
- Outdated help text mentioning deprecated usage
WHY THIS MATTERS:
- Prevents confusion about which features are current
- Reduces maintenance burden of legacy code
- Ensures commands reflect current best practices
- Maintains clean and consistent command interface
If this is documentation about deprecation for historical context,
consider moving it to separate documentation rather than keeping it
in the active command code.";
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class RsxTestFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-RSX-01';
}
public function get_name(): string
{
return 'RSX Test Files Check';
}
public function get_description(): string
{
return 'Check for test files in rsx/ directory and rsx/temp - test files should be in proper test directories';
}
public function get_file_patterns(): array
{
// This rule works differently - it checks rsx directory structure, not individual files
return [];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Check for test files in rsx/ directory (from line 1636)
* Test files should be in proper test directories, not loose in rsx/
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule needs special handling - it should be called once, not per file
// We'll handle this in the check_rsx() method instead
}
/**
* Special method to check rsx files - called once per run
*/
public function check_rsx(): void
{
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$rsx_dir = $base_path . '/rsx';
$whitelist = function_exists('config') ? config('rsx.code_quality.rsx_test_whitelist', []) : [];
// Check for temp directories only if pre-commit-tests is enabled
$pre_commit_tests = $this->config['pre_commit_tests'] ?? false;
if ($pre_commit_tests) {
// Check both rsx/temp and app/RSpade/temp
$temp_dirs = [
$rsx_dir . '/temp' => 'rsx/temp',
$base_path . '/app/RSpade/temp' => 'app/RSpade/temp'
];
foreach ($temp_dirs as $temp_dir => $temp_name) {
if (is_dir($temp_dir)) {
$temp_files = array_merge(
glob($temp_dir . '/*.php'),
glob($temp_dir . '/*.js'),
glob($temp_dir . '/*')
);
// Remove duplicates and filter out directories
$temp_files = array_unique($temp_files);
$temp_files = array_filter($temp_files, 'is_file');
if (!empty($temp_files)) {
// Files exist in temp directory - report violation
foreach ($temp_files as $file) {
$this->add_violation(
$file,
0,
"File found in {$temp_name} directory. All files in {$temp_name} should be removed prior to commit.",
basename($file),
"The {$temp_name} directory is for temporary test files during development. Remove this file before committing.",
'high' // High severity for pre-commit
);
}
} else {
// Directory exists but is empty - silently remove it
@rmdir($temp_dir);
}
}
}
}
// Get all PHP and JS files in rsx/ (not subdirectories)
$php_files = glob($rsx_dir . '/*.php');
$js_files = glob($rsx_dir . '/*.js');
$files = array_merge($php_files, $js_files);
foreach ($files as $file_path) {
$filename = basename($file_path);
// Check if filename contains 'test' (case insensitive)
if (stripos($filename, 'test') === false) {
continue; // Not a test file
}
// Skip if whitelisted
if (in_array($filename, $whitelist)) {
continue;
}
$this->add_violation(
$file_path,
0,
"Test file '{$filename}' found directly in rsx/ directory. Test files should be organized in proper test subdirectories.",
$filename,
"This appears to be a temporary test file that should be removed before commit. " .
"LLM agents often create test files for verifying specific functionality. " .
"Move this file to a proper test directory (e.g., rsx/rsx_tests/ or rsx/app/tests/) or remove it. " .
"If this file is legitimately needed in rsx/, add '{$filename}' to the whitelist in config/rsx.php under 'code_quality.rsx_test_whitelist'.",
'medium'
);
}
}
}

View File

@@ -0,0 +1,627 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class SubclassNaming_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-SUBCLASS-01';
}
public function get_name(): string
{
return 'Subclass Naming Convention';
}
public function get_description(): string
{
return 'Ensures subclasses end with the same suffix as their parent class';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js'];
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip -temp files
if (str_contains($file_path, '-temp.')) {
return;
}
// Only check files in ./rsx and ./app/RSpade
$is_rsx = str_contains($file_path, '/rsx/');
$is_rspade = str_contains($file_path, '/app/RSpade/');
if (!$is_rsx && !$is_rspade) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if ($is_rspade && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Get class and extends information from metadata
if (!isset($metadata['class']) || !isset($metadata['extends'])) {
return;
}
$class_name = $metadata['class'];
$parent_class = $metadata['extends'];
// Skip if no parent class
if (empty($parent_class)) {
return;
}
// Get suffix exempt classes from config
$suffix_exempt_classes = config('rsx.code_quality.suffix_exempt_classes', []);
// Strip FQCN prefix from parent class if present
$parent_class_simple = ltrim($parent_class, '\\');
if (str_contains($parent_class_simple, '\\')) {
$parts = explode('\\', $parent_class_simple);
$parent_class_simple = end($parts);
} else {
$parent_class_simple = $parent_class_simple;
}
// Check if parent class is in the suffix exempt list
// If it is, this child class doesn't need to follow suffix convention
// But any classes extending THIS class will need to follow convention
if (in_array($parent_class_simple, $suffix_exempt_classes)) {
// Don't check suffix for direct children of exempt classes
return;
}
// Skip if extending built-in PHP classes or Laravel framework classes
$built_in_classes = ['Exception', 'RuntimeException', 'InvalidArgumentException', 'LogicException',
'BadMethodCallException', 'DomainException', 'LengthException', 'OutOfBoundsException',
'OutOfRangeException', 'OverflowException', 'RangeException', 'UnderflowException',
'UnexpectedValueException', 'ErrorException', 'Error', 'TypeError', 'ParseError',
'AssertionError', 'ArithmeticError', 'DivisionByZeroError',
'BaseController', 'Controller', 'ServiceProvider', 'Command', 'Model',
'Seeder', 'Factory', 'Migration', 'Request', 'Resource', 'Policy'];
if (in_array($parent_class_simple, $built_in_classes)) {
return;
}
// Also check if the parent of parent is exempt - for deeper inheritance
// E.g., if Widget extends Jqhtml_Component, and Dynamic_Widget extends Widget
// Dynamic_Widget should match Widget's suffix
$parent_of_parent = $this->get_parent_class($parent_class_simple);
if ($parent_of_parent && !in_array($parent_of_parent, $suffix_exempt_classes)) {
// Parent's parent is not exempt, so check suffix based on parent
// Continue with normal suffix checking
}
// Extract the suffix from parent class (use original for suffix extraction)
$suffix = $this->extract_suffix($parent_class);
if (empty($suffix)) {
// This is a violation - parent class name is malformed
$this->add_violation(
$file_path,
0,
"Cannot extract suffix from parent class '$parent_class' - parent class name may be malformed",
"class $class_name extends $parent_class",
$this->get_parent_class_suffix_error($parent_class),
'high'
);
return;
}
// Check if child class is abstract based on metadata or class name
$child_is_abstract = isset($metadata['is_abstract']) ? $metadata['is_abstract'] : str_ends_with($class_name, '_Abstract');
// CRITICAL LOGIC: If parent suffix contains "Abstract" and child is NOT abstract,
// remove "Abstract" from the expected suffix
// Example: AbstractRule (parent) → *_Rule (child, not *_AbstractRule)
if (!$child_is_abstract && str_contains($suffix, 'Abstract')) {
// Remove "Abstract" from suffix for non-abstract children
$suffix = str_replace('Abstract', '', $suffix);
// Clean up any double underscores or leading/trailing underscores
$suffix = trim($suffix, '_');
if (empty($suffix)) {
// If suffix becomes empty after removing Abstract, skip validation
// This handles edge cases like a class named just "Abstract"
return;
}
}
// Special handling for abstract classes
if ($child_is_abstract) {
// This is an abstract class - it should have the parent suffix as second-to-last term
$result = $this->check_abstract_class_naming($class_name, $suffix);
if (!$result['valid']) {
$this->add_violation(
$file_path,
0,
$result['message'],
"class $class_name extends $parent_class",
$result['remediation'],
'high'
);
}
return; // Don't check filename for abstract classes - different rules apply
}
// Check if child class suffix contains parent suffix (compound suffix issue)
$child_suffix = $this->extract_child_suffix($class_name);
$compound_suffix_issue = false;
if ($child_suffix && $child_suffix !== $suffix && str_ends_with($child_suffix, $suffix)) {
// Child has compound suffix like ServiceProvider when parent is Provider
$compound_suffix_issue = true;
}
// Check 1: Class name must end with appropriate suffix
$class_name_valid = $this->check_class_name_suffix($class_name, $suffix, $is_rsx);
if (!$class_name_valid || $compound_suffix_issue) {
// Determine expected suffix based on location and Rsx prefix
$expected_suffix = $this->get_expected_suffix($suffix, $is_rsx);
$this->add_violation(
$file_path,
0,
"Class '$class_name' extends '$parent_class' but doesn't end with '_{$expected_suffix}'",
"class $class_name extends $parent_class",
$this->get_class_name_remediation($class_name, $parent_class, $suffix, $is_rsx, $compound_suffix_issue, $child_suffix),
'high'
);
return; // Don't check filename if class name is wrong
}
// Check 2: Filename must follow convention (only if class name is valid)
$filename = basename($file_path);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
if (!$this->check_filename_convention($filename, $class_name, $suffix, $extension)) {
$expected_suffix = $this->get_expected_suffix($suffix, $is_rsx);
$this->add_violation(
$file_path,
0,
"Filename '$filename' doesn't follow naming convention for class '$class_name'",
"File: $filename",
$this->get_filename_remediation($class_name, $filename, $expected_suffix, $extension, $is_rsx),
'medium'
);
}
}
private function extract_suffix(string $parent_class): string
{
// Strip FQCN prefix if present
$parent_class = ltrim($parent_class, '\\');
if (str_contains($parent_class, '\\')) {
$parts = explode('\\', $parent_class);
$parent_class = end($parts);
}
// Split by underscores
$parts = explode('_', $parent_class);
// If no underscores, check for special cases
if (count($parts) === 1) {
// For single-word classes, return the whole name as suffix
// This includes cases like "AbstractRule", "BaseController", etc.
return $parent_class;
}
// Find the last part that is NOT "Abstract"
for ($i = count($parts) - 1; $i >= 0; $i--) {
if ($parts[$i] !== 'Abstract') {
// If this is a multi-part suffix like ManifestBundle_Abstract
// we need to get everything from this point backwards until we hit a proper boundary
// Special case: if the parent ends with _Abstract, get everything before it
if (str_ends_with($parent_class, '_Abstract')) {
$pos = strrpos($parent_class, '_Abstract');
$before_abstract = substr($parent_class, 0, $pos);
// Get the last "word" which could be multi-part
$before_parts = explode('_', $before_abstract);
// If it's something like Manifest_Bundle_Abstract, suffix is "Bundle"
// If it's something like ManifestBundle_Abstract, suffix is "ManifestBundle"
if (count($before_parts) > 0) {
return $before_parts[count($before_parts) - 1];
}
}
return $parts[$i];
}
}
// If we couldn't extract a suffix, return empty string
// This will trigger a violation in the check method
return '';
}
private function check_class_name_suffix(string $class_name, string $suffix, bool $is_rsx): bool
{
// Special case: Allow class name to be the same as the suffix
// e.g., Main extending Main_Abstract
if ($class_name === $suffix) {
return true;
}
// Special handling for Rsx-prefixed suffixes
if (str_starts_with($suffix, 'Rsx')) {
// Remove 'Rsx' prefix to get the base suffix
$base_suffix = substr($suffix, 3);
if ($is_rsx) {
// In /rsx/ directory: child class should use suffix WITHOUT 'Rsx'
// e.g., Demo_Bundle extends Rsx_Bundle_Abstract ✓
return str_ends_with($class_name, '_' . $base_suffix);
} else {
// In /app/RSpade/ directory: child class can use suffix WITH or WITHOUT 'Rsx'
// e.g., Cool_Rule extends RsxRule ✓ OR Cool_RsxRule extends RsxRule ✓
return str_ends_with($class_name, '_' . $suffix) ||
str_ends_with($class_name, '_' . $base_suffix);
}
}
// Standard suffix handling (non-Rsx prefixed)
// Class name must end with _{Suffix}
$expected_ending = '_' . $suffix;
return str_ends_with($class_name, $expected_ending);
}
private function check_filename_convention(string $filename, string $class_name, string $suffix, string $extension): bool
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// Special case: When class name equals suffix (e.g., Main extending Main_Abstract)
// Allow either exact match (Main.php) or lowercase (main.php)
if ($class_name === $suffix) {
if ($filename_without_ext === $class_name || $filename_without_ext === strtolower($class_name)) {
return true;
}
}
// Two valid patterns:
// 1. Exact match to class name: User_Model.php
if ($filename_without_ext === $class_name) {
return true;
}
// 2. Ends with underscore + lowercase suffix: anything_model.php
// For Rsx-prefixed suffixes, use the base suffix (without Rsx) in lowercase
$actual_suffix = str_starts_with($suffix, 'Rsx') ? substr($suffix, 3) : $suffix;
$lowercase_suffix = strtolower($actual_suffix);
if (str_ends_with($filename_without_ext, '_' . $lowercase_suffix)) {
return true;
}
return false;
}
private function extract_child_suffix(string $class_name): string
{
$parts = explode('_', $class_name);
if (count($parts) > 1) {
return $parts[count($parts) - 1];
}
return '';
}
private function get_class_name_remediation(string $class_name, string $parent_class, string $suffix, bool $is_rsx, bool $compound_suffix_issue = false, string $child_suffix = ''): string
{
// Check if this is about Abstract suffix handling
$is_abstract_suffix_issue = str_contains($parent_class, 'Abstract') && !str_ends_with($class_name, '_Abstract');
// Try to suggest a better class name
$suggested_class = $this->suggest_class_name($class_name, $suffix, $is_rsx, $compound_suffix_issue, $child_suffix);
$expected_suffix = $this->get_expected_suffix($suffix, $is_rsx);
// Check if this involves Laravel classes
$laravel_classes = ['BaseController', 'Controller', 'ServiceProvider', 'Command', 'Model',
'Seeder', 'Factory', 'Migration', 'Request', 'Resource', 'Policy'];
$is_laravel_involved = false;
foreach ($laravel_classes as $laravel_class) {
if (str_contains($parent_class, $laravel_class) || str_contains($class_name, $laravel_class)) {
$is_laravel_involved = true;
break;
}
}
$compound_suffix_section = '';
if ($compound_suffix_issue && $child_suffix) {
$compound_suffix_section = "\n\nCOMPOUND SUFFIX DETECTED:\nYour class uses '$child_suffix' when the parent uses '$suffix'.\nThis creates ambiguity. The suffix should be split with underscores.\nFor example: 'ServiceProvider' should become 'Service_Provider'\n";
}
$laravel_section = '';
if ($is_laravel_involved) {
$laravel_section = "\n\nLARAVEL CLASS DETECTED:\nEven though this involves Laravel framework classes, the RSX naming convention STILL APPLIES.\nRSX enforces its own conventions uniformly across all code.\nLaravel's PascalCase conventions are overridden by RSX's underscore notation.\n";
}
$abstract_handling_note = '';
if ($is_abstract_suffix_issue) {
$abstract_handling_note = "\n\nABSTRACT SUFFIX HANDLING:\n" .
"When a parent class contains 'Abstract' in its name (like '$parent_class'),\n" .
"non-abstract child classes should use the suffix WITHOUT 'Abstract'.\n" .
"This is because concrete implementations should not have 'Abstract' in their names.\n";
}
return "CLASS NAMING CONVENTION VIOLATION" . $compound_suffix_section . $laravel_section . $abstract_handling_note . "
Class '$class_name' extends '$parent_class' but doesn't follow RSX naming conventions.
REQUIRED SUFFIX: '$expected_suffix'
All classes extending '$parent_class' must end with '_{$expected_suffix}'
RSX NAMING PATTERN:
RSpade uses underscore notation for class names, separating major conceptual parts:
CORRECT: User_Downloads_Model, Site_User_Model, Php_ManifestModule
WRONG: UserDownloadsModel, SiteUserModel, PhpManifestModule
SUFFIX CONVENTION:
The suffix (last part after underscore) can be multi-word without underscores to describe the class type:
- 'Model' suffix for database models
- 'Controller' suffix for controllers
- 'ManifestModule' suffix for manifest module implementations
- 'BundleProcessor' suffix for bundle processors
These multi-word suffixes act as informal type declarations (e.g., Php_ManifestModule indicates a PHP implementation of a manifest module).
RSX PREFIX SPECIAL RULE:
- In /rsx/ directory: If parent class has 'Rsx' prefix (e.g., Rsx_Bundle_Abstract), child uses suffix WITHOUT 'Rsx' (e.g., Demo_Bundle)
- In /app/RSpade/ directory: Child can use suffix WITH or WITHOUT 'Rsx' prefix (e.g., Cool_Rule or Cool_RsxRule extending RsxRule)
SUGGESTED CLASS NAME: $suggested_class
The filename should also end with:
- For files in /rsx: underscore + suffix in lowercase (e.g., user_downloads_model.php)
- For files in /app/RSpade: suffix matching class name case (e.g., User_Downloads_Model.php)
REMEDIATION STEPS:
1. Rename class from '$class_name' to '$suggested_class'
2. Update filename to match convention
3. Update all references to this class throughout the codebase
WHY THIS MATTERS:
- Enables automatic class discovery and loading
- Makes inheritance relationships immediately clear
- Maintains consistency across the entire codebase
- Supports framework introspection capabilities";
}
private function get_filename_remediation(string $class_name, string $filename, string $suffix, string $extension, bool $is_rsx): string
{
// Suggest filenames based on directory
$lowercase_suggestion = strtolower(str_replace('_', '_', $class_name)) . ".$extension";
$exact_suggestion = $class_name . ".$extension";
$recommended = $is_rsx ? $lowercase_suggestion : $exact_suggestion;
return "FILENAME CONVENTION VIOLATION
Filename '$filename' doesn't follow naming convention for class '$class_name'
VALID FILENAME PATTERNS:
1. Underscore + lowercase suffix: *_" . strtolower($suffix) . ".$extension
2. Exact class name match: $class_name.$extension
CURRENT FILE: $filename
CURRENT CLASS: $class_name
RECOMMENDED FIX:
- For /rsx directory: $lowercase_suggestion
- For /app/RSpade directory: $exact_suggestion
Note: Both patterns are valid in either directory, but these are the conventions.
EXAMPLES OF VALID FILENAMES:
- user_model.$extension (underscore + lowercase suffix)
- site_user_model.$extension (underscore + lowercase suffix)
- $class_name.$extension (exact match)
WHY THIS MATTERS:
- Enables predictable file discovery
- Maintains consistency with directory conventions
- Supports autoloading mechanisms";
}
private function suggest_class_name(string $current_name, string $suffix, bool $is_rsx, bool $compound_suffix_issue = false, string $child_suffix = ''): string
{
// Handle compound suffix issue specially
if ($compound_suffix_issue && $child_suffix) {
// Split the compound suffix with underscores
// E.g., ServiceProvider -> Service_Provider
$split_suffix = $this->split_compound_suffix($child_suffix, $suffix);
if ($split_suffix) {
// Replace the compound suffix with the split version
$base = substr($current_name, 0, -strlen($child_suffix));
return $base . $split_suffix;
}
}
// Determine the suffix to use based on location and Rsx prefix
$target_suffix = $this->get_expected_suffix($suffix, $is_rsx);
// If it already ends with the target suffix (but without underscore), add underscore
if (str_ends_with($current_name, $target_suffix) && !str_ends_with($current_name, '_' . $target_suffix)) {
$without_suffix = substr($current_name, 0, -strlen($target_suffix));
// Convert camelCase to snake_case if needed
$snake_case = preg_replace('/([a-z])([A-Z])/', '$1_$2', $without_suffix);
return $snake_case . '_' . $target_suffix;
}
// Otherwise, just append _Suffix
// Convert the current name to proper underscore notation first
$snake_case = preg_replace('/([a-z])([A-Z])/', '$1_$2', $current_name);
return $snake_case . '_' . $target_suffix;
}
private function get_expected_suffix(string $suffix, bool $is_rsx): string
{
// For Rsx-prefixed suffixes in /rsx/ directory, use suffix without 'Rsx'
if (str_starts_with($suffix, 'Rsx') && $is_rsx) {
return substr($suffix, 3);
}
// Otherwise use the full suffix
return $suffix;
}
private function get_parent_class_suffix_error(string $parent_class): string
{
return "PARENT CLASS SUFFIX EXTRACTION ERROR
Unable to extract a valid suffix from parent class '$parent_class'.
This is an unexpected situation that indicates either:
1. The parent class name has an unusual format that the rule doesn't handle
2. The naming rule logic needs to be updated to handle this case
EXPECTED PARENT CLASS FORMATS:
- Classes with underscores: Last part after underscore is the suffix (e.g., 'Rsx_Model_Abstract' suffix 'Model')
- Classes ending with 'Abstract': Remove 'Abstract' to get suffix (e.g., 'RuleAbstract' suffix 'Rule')
- Classes ending with '_Abstract': Part before '_Abstract' is suffix (e.g., 'Model_Abstract' suffix 'Model')
- Multi-word suffixes: 'ManifestModule_Abstract' suffix 'ManifestModule'
PLEASE REVIEW:
1. Check if the parent class name follows RSX naming conventions
2. If the parent class name is valid but unusual, the SubclassNamingRule may need updating
3. Consider renaming the parent class to follow standard patterns
This violation indicates a framework-level issue that needs attention from the development team.";
}
private function split_compound_suffix(string $compound, string $parent_suffix): string
{
// If compound ends with parent suffix, split it
if (str_ends_with($compound, $parent_suffix)) {
$prefix = substr($compound, 0, -strlen($parent_suffix));
if ($prefix) {
return $prefix . '_' . $parent_suffix;
}
}
return '';
}
private function check_abstract_class_naming(string $class_name, string $parent_suffix): array
{
// Strip Base prefix from parent suffix if present
if (str_starts_with($parent_suffix, 'Base')) {
$parent_suffix = substr($parent_suffix, 4);
}
// Get the parts of the abstract class name
$parts = explode('_', $class_name);
// Must have at least 2 parts (Something_Abstract)
if (count($parts) < 2) {
return [
'valid' => false,
'message' => "Abstract class '$class_name' doesn't follow underscore notation",
'remediation' => "Abstract classes must use underscore notation and end with '_Abstract'.\nExample: User_Controller_Abstract, Rsx_Model_Abstract"
];
}
// Last part must be 'Abstract'
if ($parts[count($parts) - 1] !== 'Abstract') {
return [
'valid' => false,
'message' => "Class '$class_name' appears to be abstract but doesn't end with '_Abstract'",
'remediation' => "All abstract classes must end with '_Abstract'.\nSuggested: " . implode('_', array_slice($parts, 0, -1)) . "_Abstract"
];
}
// If only 2 parts (Something_Abstract), that's valid for root abstracts
if (count($parts) == 2) {
return ['valid' => true];
}
// For multi-part names, check that second-to-last term matches parent suffix
$second_to_last = $parts[count($parts) - 2];
if ($second_to_last !== $parent_suffix) {
// Build suggested name
$suggested_parts = array_slice($parts, 0, -2); // Everything except last 2 parts
$suggested_parts[] = $parent_suffix;
$suggested_parts[] = 'Abstract';
$suggested_name = implode('_', $suggested_parts);
return [
'valid' => false,
'message' => "Abstract class '$class_name' doesn't properly indicate it extends a '$parent_suffix' type",
'remediation' => "ABSTRACT CLASS NAMING CONVENTION\n\n" .
"Abstract classes must:\n" .
"1. End with '_Abstract'\n" .
"2. Have the parent type as the second-to-last term\n\n" .
"Current: $class_name\n" .
"Expected pattern: *_{$parent_suffix}_Abstract\n" .
"Suggested: $suggested_name\n\n" .
"This makes the inheritance chain clear:\n" .
"- Parent provides: $parent_suffix functionality\n" .
"- This class: Abstract extension of $parent_suffix\n\n" .
"Note: If the parent class starts with 'Base' (e.g., BaseController),\n" .
"we strip 'Base' to get the actual type (Controller)."
];
}
return ['valid' => true];
}
/**
* Get the parent class of a given class from manifest or other sources
*/
private function get_parent_class(string $class_name): ?string
{
// Try to get from manifest
try {
$metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_class($class_name);
if (!empty($metadata) && isset($metadata['extends'])) {
$parent = $metadata['extends'];
// Strip namespace if present
if (str_contains($parent, '\\')) {
$parts = explode('\\', $parent);
return end($parts);
}
return $parent;
}
} catch (\RuntimeException $e) {
// Class not in manifest (e.g., framework classes like DatabaseSessionHandler)
// Return null since we can't check parent of external classes
return null;
}
return null;
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class TempFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-TEMP-01';
}
public function get_name(): string
{
return 'Temporary Files Check';
}
public function get_description(): string
{
return 'Check for temporary files ending in -temp that should be removed before commit';
}
public function get_file_patterns(): array
{
return ['*']; // Check all files
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for files ending in -temp before their extension
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only run during pre-commit tests
$pre_commit_tests = $this->config['pre_commit_tests'] ?? false;
if (!$pre_commit_tests) {
return;
}
// Check if filename contains -temp before the extension
$filename = basename($file_path);
// Match files like test-temp.php, module-temp.js, etc.
if (preg_match('/^(.+)-temp(\.[^.]+)?$/', $filename, $matches)) {
$this->add_violation(
$file_path,
0,
"Temporary file '{$filename}' detected. Files ending in '-temp' should be removed before commit.",
$filename,
"The '-temp' suffix indicates this is a temporary file for testing or development.\n" .
"These files should not be committed to the repository.\n\n" .
"Options:\n" .
"1. Remove the file if it's no longer needed\n" .
"2. Rename the file without '-temp' if it should be kept\n" .
"3. Move to a proper test directory if it's a test file",
'high'
);
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class BundleIncludePath_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-BUNDLE-02';
}
public function get_name(): string
{
return 'Bundle Include Path Convention';
}
public function get_description(): string
{
return 'Bundles should include __DIR__ or their relative path in their includes';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'low';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a PHP class file
if (!isset($metadata['class'])) {
return;
}
// Only check files in ./rsx/ directory, trust framework authors for app/RSpade
if (!str_starts_with($file_path, base_path() . '/rsx/')) {
return;
}
// Check if class extends Rsx_Bundle_Abstract
$extends = $metadata['extends'] ?? '';
if ($extends !== 'Rsx_Bundle_Abstract' &&
$extends !== 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
$dir_path = dirname($relative_path);
// Check if bundle includes its own directory in the include array
// Look for the define() method
if (!preg_match('/public\s+static\s+function\s+define\s*\(\s*\)\s*:\s*array\s*\{(.*?)\}/s', $contents, $matches)) {
return; // Can't find define method
}
$define_content = $matches[1];
// Look for include array
if (!preg_match("/['\"]include['\"]\s*=>\s*\[(.*?)\]/s", $define_content, $include_matches)) {
return; // No include array found
}
$include_content = $include_matches[1];
// Check if it references __DIR__ or the directory path
$has_dir_reference = false;
// Check for __DIR__ usage
if (str_contains($include_content, '__DIR__')) {
$has_dir_reference = true;
}
// Check for the relative directory path (e.g., 'rsx/app/demo')
if (str_contains($include_content, "'" . $dir_path) ||
str_contains($include_content, '"' . $dir_path)) {
$has_dir_reference = true;
}
if (!$has_dir_reference) {
// Get bundle class name for better message
$class_name = $metadata['class'] ?? basename($file_path, '.php');
$this->add_violation(
$file_path,
0,
"Bundle {$class_name} should include its own directory in the 'include' array",
null,
"Add '__DIR__' or '{$dir_path}' to the bundle's include array to ensure all module files are included.\n" .
"Note: This is a convention rather than a hard requirement. If your bundle intentionally doesn't need " .
"to include its own directory, add the following comment to grant an exception: @CONV-BUNDLE-02-EXCEPTION",
$this->get_default_severity()
);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class BundleLocation_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-BUNDLE-01';
}
public function get_name(): string
{
return 'Bundle Location Convention';
}
public function get_description(): string
{
return 'Bundles should be in ./rsx/app or ./rsx/app/(module)/ but not deeper';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'convention';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a PHP class file
if (!isset($metadata['class'])) {
return;
}
// Check if class extends Rsx_Bundle_Abstract
$extends = $metadata['extends'] ?? '';
if ($extends !== 'Rsx_Bundle_Abstract' &&
$extends !== 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Check if it's in rsx/app directory
if (!str_starts_with($relative_path, 'rsx/app/')) {
return; // Bundle is not in rsx/app, that's ok (could be in rsx/lib etc)
}
// Count directory levels after rsx/app/
$after_app = substr($relative_path, strlen('rsx/app/'));
$parts = explode('/', $after_app);
// If more than 2 levels deep (module/feature/file.php), it's a violation
if (count($parts) > 2) {
$this->add_violation(
$file_path,
0,
'Bundle class should be in ./rsx/app/ or ./rsx/app/(module)/ but not in feature subdirectories',
null,
'Move this bundle to ./rsx/app/ if used globally, or to ./rsx/app/' . $parts[0] . '/ if module-specific',
'convention'
);
}
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* FilenameRedundantPrefix_CodeQualityRule - Detects unnecessarily long filenames
*
* Suggests using short filenames when the directory structure already contains the prefix.
*/
class FilenameRedundantPrefix_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-FILENAME-01';
}
public function get_name(): string
{
return 'Filename Redundant Prefix Convention';
}
public function get_description(): string
{
return 'Suggests using short filenames when directory structure contains the prefix';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.jqhtml', '*.blade.php'];
}
public function get_default_severity(): string
{
return 'convention';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx or ./app/RSpade
$relative_path = str_replace(base_path() . '/', '', $file_path);
$is_rsx = str_starts_with($relative_path, 'rsx/');
$is_rspade = str_starts_with($relative_path, 'app/RSpade/');
if (!$is_rsx && !$is_rspade) {
return;
}
$extension = $metadata['extension'] ?? '';
$filename = basename($file_path);
// Check PHP/JS files with classes
if (isset($metadata['class'])) {
$this->check_class_redundancy($relative_path, $metadata['class'], $extension, $filename, $is_rspade);
}
// Check blade.php files with @rsx_id
if ($extension === 'blade.php' && isset($metadata['id'])) {
$this->check_blade_redundancy($relative_path, $metadata['id'], $filename, $is_rspade);
}
// Check jqhtml files with Define:
if ($extension === 'jqhtml' && isset($metadata['id'])) {
$this->check_jqhtml_redundancy($relative_path, $metadata['id'], $filename, $is_rspade);
}
}
private function check_class_redundancy(string $file, string $class_name, string $extension, string $filename, bool $is_rspade): void
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
$dir_path = dirname($file);
$short_name = $this->extract_short_name($class_name, $dir_path);
if ($short_name === null) {
return; // No short name available
}
// Check if current filename is the full name (redundant)
$is_full_name = $is_rspade
? $filename_without_ext === $class_name
: strtolower($filename_without_ext) === strtolower($class_name);
if (!$is_full_name) {
return; // Not using full name
}
// Check if short filename would be available
$short_filename = $is_rspade ? $short_name . '.' . $extension : strtolower($short_name) . '.' . $extension;
$short_path = dirname(base_path($file)) . '/' . $short_filename;
if (file_exists($short_path)) {
return; // Short name already taken
}
$this->add_violation(
$file,
1,
"Filename contains redundant prefix already represented in directory structure",
"class $class_name",
"Directory structure already contains the class name prefix.\n" .
"Consider using the shorter filename: '$short_filename'\n" .
" mv '$filename' '$short_filename'\n\n" .
"IMPORTANT: Only the filename should be changed. The class name must remain unchanged.\n" .
"The directory structure provides the necessary context for the shorter filename.\n" .
"Example: rsx/app/demo/demo_controller.php → rsx/app/demo/controller.php\n" .
" (but class Demo_Controller remains Demo_Controller)\n\n" .
"This improves readability while maintaining uniqueness.",
'convention'
);
}
private function check_blade_redundancy(string $file, string $rsx_id, string $filename, bool $is_rspade): void
{
$filename_without_blade = str_replace('.blade.php', '', $filename);
$dir_path = dirname($file);
$short_name = $this->extract_short_name($rsx_id, $dir_path);
if ($short_name === null) {
return;
}
$is_full_name = $is_rspade
? $filename_without_blade === $rsx_id
: strtolower($filename_without_blade) === strtolower($rsx_id);
if (!$is_full_name) {
return;
}
$short_filename = $is_rspade ? $short_name . '.blade.php' : strtolower($short_name) . '.blade.php';
$short_path = dirname(base_path($file)) . '/' . $short_filename;
if (file_exists($short_path)) {
return;
}
$this->add_violation(
$file,
1,
"Blade filename contains redundant prefix already represented in directory structure",
"@rsx_id('$rsx_id')",
"Directory structure already contains the @rsx_id prefix.\n" .
"Consider using the shorter filename: '$short_filename'\n" .
" mv '$filename' '$short_filename'\n\n" .
"IMPORTANT: Only the filename should be changed. The @rsx_id must remain unchanged.\n" .
"The directory structure provides the necessary context for the shorter filename.\n" .
"Example: rsx/app/demo/sections/demo_sections_cards.blade.php → cards.blade.php\n" .
" (but @rsx_id('demo.sections.cards') remains demo.sections.cards)\n\n" .
"This improves readability while maintaining uniqueness.",
'convention'
);
}
private function check_jqhtml_redundancy(string $file, string $component_name, string $filename, bool $is_rspade): void
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
$dir_path = dirname($file);
$short_name = $this->extract_short_name($component_name, $dir_path);
if ($short_name === null) {
return;
}
$is_full_name = $is_rspade
? $filename_without_ext === $component_name
: strtolower($filename_without_ext) === strtolower($component_name);
if (!$is_full_name) {
return;
}
$short_filename = $is_rspade ? $short_name . '.jqhtml' : strtolower($short_name) . '.jqhtml';
$short_path = dirname(base_path($file)) . '/' . $short_filename;
if (file_exists($short_path)) {
return;
}
$this->add_violation(
$file,
1,
"Jqhtml filename contains redundant prefix already represented in directory structure",
"<Define:$component_name>",
"Directory structure already contains the component name prefix.\n" .
"Consider using the shorter filename: '$short_filename'\n" .
" mv '$filename' '$short_filename'\n\n" .
"IMPORTANT: Only the filename should be changed. The component name must remain unchanged.\n" .
"The directory structure provides the necessary context for the shorter filename.\n" .
"Example: rsx/app/demo/components/demo_card.jqhtml → card.jqhtml\n" .
" (but <Define:Demo_Card> remains Demo_Card)\n\n" .
"This improves readability while maintaining uniqueness.",
'convention'
);
}
/**
* Extract short name from class/id if directory structure matches prefix
* Returns null if directory structure doesn't match or if short name rules aren't met
*
* Rules:
* - Original name must have 3+ segments for short name to be allowed (2-segment names must use full name)
* - Short name must have 2+ segments (exception: if original was 1 segment, short can be 1 segment)
*/
private function extract_short_name(string $full_name, string $dir_path): ?string
{
$name_parts = explode('_', $full_name);
$original_segment_count = count($name_parts);
// If original name has exactly 2 segments, short name is NOT allowed
if ($original_segment_count === 2) {
return null;
}
// If only 1 segment, no prefix to match
if ($original_segment_count === 1) {
return null;
}
// Split directory path into parts and re-index
$dir_parts = array_values(array_filter(explode('/', $dir_path)));
// Find the maximum number of consecutive matches between end of dir_parts and start of name_parts
$matched_parts = 0;
$max_possible = min(count($dir_parts), count($name_parts) - 1);
// Try to match last N dir parts with first N name parts
for ($num_to_check = $max_possible; $num_to_check > 0; $num_to_check--) {
$all_match = true;
for ($i = 0; $i < $num_to_check; $i++) {
$dir_idx = count($dir_parts) - $num_to_check + $i;
if (strtolower($dir_parts[$dir_idx]) !== strtolower($name_parts[$i])) {
$all_match = false;
break;
}
}
if ($all_match) {
$matched_parts = $num_to_check;
break;
}
}
if ($matched_parts === 0) {
return null;
}
// Calculate the short name
$short_parts = array_slice($name_parts, $matched_parts);
$short_segment_count = count($short_parts);
// Validate short name segment count
// Short name must have 2+ segments (unless original was 1 segment, which we already excluded above)
if ($short_segment_count < 2) {
return null; // Short name would be too short
}
return implode('_', $short_parts);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class LayoutLocation_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-LAYOUT-01';
}
public function get_name(): string
{
return 'Layout File Location Convention';
}
public function get_description(): string
{
return 'Layout blade files in ./rsx/ must be within a module directory (./rsx/app/(module)/ or deeper)';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'convention';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Check if filename ends with layout.blade.php
if (!preg_match('/_layout\.blade\.php$/', $file_path)) {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Only check layouts in rsx/ directory (not app/RSpade)
if (!str_starts_with($relative_path, 'rsx/')) {
return;
}
// Check if it's in rsx/app directory
if (!str_starts_with($relative_path, 'rsx/app/')) {
$this->add_violation(
$file_path,
0,
'Layout file must be within ./rsx/app/ directory',
null,
'Move this layout to ./rsx/app/(module)/ or a subdirectory within a module',
'convention'
);
return;
}
// Count directory levels after rsx/app/
$after_app = substr($relative_path, strlen('rsx/app/'));
$parts = explode('/', $after_app);
// Layout must be at least 2 levels deep: module/file.php
if (count($parts) < 2) {
$this->add_violation(
$file_path,
0,
'Layout file must be within a module directory, not directly in ./rsx/app/',
null,
'Move this layout to ./rsx/app/(module)/ or a subdirectory within a module',
'convention'
);
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class OneBundlePerModule_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-BUNDLE-03';
}
public function get_name(): string
{
return 'One Bundle Per Module Directory Convention';
}
public function get_description(): string
{
return 'Module directories should have only one bundle (./rsx/app root can have multiple)';
}
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
* bundle organization is a critical framework convention for module structure.
*/
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
{
// Skip if not a PHP class file
if (!isset($metadata['class'])) {
return;
}
// Check if class extends Rsx_Bundle_Abstract
$extends = $metadata['extends'] ?? '';
if ($extends !== 'Rsx_Bundle_Abstract' &&
$extends !== 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
$dir_path = dirname($relative_path);
// Skip if bundle is directly in rsx/app (they can have multiple)
if ($dir_path === 'rsx/app') {
return;
}
// Skip if not in rsx/app
if (!str_starts_with($dir_path, 'rsx/app/')) {
return;
}
// Check the manifest for other bundles in the same directory
$manifest = Manifest::get_all();
$bundles_in_same_dir = [];
foreach ($manifest as $path => $file_metadata) {
// Check if it's a PHP file in the same directory
if (dirname($path) !== $dir_path) {
continue;
}
// Check if it's a bundle class
if (isset($file_metadata['class']) && isset($file_metadata['extends'])) {
$file_extends = $file_metadata['extends'];
if ($file_extends === 'Rsx_Bundle_Abstract' ||
$file_extends === 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
$bundles_in_same_dir[] = basename($path, '.php');
}
}
}
// If there's more than one bundle in this directory, throw an exception
if (count($bundles_in_same_dir) > 1) {
$error_message = "Code Quality Violation (CONV-BUNDLE-03) - Multiple Bundles in Same Directory\n\n";
$error_message .= "Module directory '{$dir_path}' has multiple bundle files:\n";
foreach ($bundles_in_same_dir as $bundle_name) {
$error_message .= " - {$bundle_name}.php\n";
}
$error_message .= "\nCRITICAL: Each module directory should have only ONE bundle.\n\n";
$error_message .= "Resolution:\n";
$error_message .= "1. Consolidate these bundles into a single bundle file\n";
$error_message .= "2. OR move extra bundles to their own module directories\n";
$error_message .= "3. OR move them to ./rsx/app/ (which allows multiple bundles)\n\n";
$error_message .= "This convention ensures clean module organization and predictable bundle loading.";
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
1
);
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class AjaxReturnValue_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-AJAX-02';
}
public function get_name(): string
{
return 'AJAX Return Value Property Check';
}
public function get_description(): string
{
return "Detects unnecessary access to _ajax_return_value property in AJAX responses";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Check JavaScript files for _ajax_return_value property access
* Ajax.call() already unwraps the response, so accessing _ajax_return_value is unnecessary
* Instead of: data._ajax_return_value.user_id
* Should use: data.user_id
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip Ajax.js itself
if (str_ends_with($file_path, '/Ajax.js')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check files in rsx/ or app/RSpade/
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Check for _ajax_return_value usage
if (str_contains($line, '_ajax_return_value')) {
// Try to extract context for better suggestion
$suggestion = "Ajax.call() automatically unwraps the server response. ";
// Check if we can detect the specific property being accessed
if (preg_match('/(\w+)\._ajax_return_value\.(\w+)/', $line, $matches)) {
$variable = $matches[1];
$property = $matches[2];
$suggestion .= "Instead of '{$variable}._ajax_return_value.{$property}', ";
$suggestion .= "use '{$variable}.{$property}' directly.";
} elseif (preg_match('/(\w+)\._ajax_return_value/', $line, $matches)) {
$variable = $matches[1];
$suggestion .= "Instead of '{$variable}._ajax_return_value', ";
$suggestion .= "use '{$variable}' directly - it already contains the unwrapped response.";
} else {
$suggestion .= "The response from Ajax.call() is already unwrapped, ";
$suggestion .= "so you can access properties directly without the _ajax_return_value wrapper.";
}
$this->add_violation(
$file_path,
$line_number,
"Unnecessary access to '_ajax_return_value' property detected.",
trim($line),
$suggestion,
'medium'
);
}
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,290 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
use App\RSpade\Core\Manifest\Manifest;
/**
* Check decorator usage and enforce whitelisting rules
* - Functions/methods marked with @decorator become whitelisted
* - Global functions can only use @decorator
* - Static and instance methods can only use whitelisted decorators
* - Checks for duplicate global function names
* - Checks for duplicate global const names
* - Checks for conflicts between global function and const names
*/
class DecoratorUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
private array $decorator_whitelist = [];
private array $all_global_functions = [];
private array $all_global_constants = [];
private array $all_global_names = []; // Combined functions and constants for conflict checking
public function get_id(): string
{
return 'JS-DECORATOR-01';
}
public function get_name(): string
{
return 'JavaScript Decorator Usage Check';
}
public function get_description(): string
{
return 'Validates JavaScript decorator usage and whitelisting (static and instance methods)';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
/**
* Run during manifest build for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only process during manifest-time when we have all files
// This rule needs to scan all files first to build the whitelist
// So we run it once at the end of manifest building
static $already_run = false;
if ($already_run) {
return;
}
// On the first JavaScript file, process all files
if (!empty($metadata) && $metadata['extension'] === 'js') {
$this->process_all_files();
$already_run = true;
}
}
private function process_all_files(): void
{
// Get all files from manifest
$files = Manifest::get_all();
// Step 1: Build decorator whitelist and collect all global functions
foreach ($files as $path => $metadata) {
// Skip non-JavaScript files
if (($metadata['extension'] ?? '') !== 'js') {
continue;
}
// Collect global function names for uniqueness check
if (!empty($metadata['global_function_names'])) {
foreach ($metadata['global_function_names'] as $func_name) {
if (isset($this->all_global_functions[$func_name])) {
// Duplicate function name
$existing_file = $this->all_global_functions[$func_name];
$this->throw_duplicate_global($func_name, 'function', $existing_file, $path);
}
$this->all_global_functions[$func_name] = $path;
// Check for conflict with const names
if (isset($this->all_global_constants[$func_name])) {
$existing_file = $this->all_global_constants[$func_name];
$this->throw_name_conflict($func_name, 'function', $path, 'const', $existing_file);
}
$this->all_global_names[$func_name] = ['type' => 'function', 'file' => $path];
}
}
// Collect global const names for uniqueness check
if (!empty($metadata['global_const_names'])) {
foreach ($metadata['global_const_names'] as $const_name) {
if (isset($this->all_global_constants[$const_name])) {
// Duplicate const name
$existing_file = $this->all_global_constants[$const_name];
$this->throw_duplicate_global($const_name, 'const', $existing_file, $path);
}
$this->all_global_constants[$const_name] = $path;
// Check for conflict with function names
if (isset($this->all_global_functions[$const_name])) {
$existing_file = $this->all_global_functions[$const_name];
$this->throw_name_conflict($const_name, 'const', $path, 'function', $existing_file);
}
$this->all_global_names[$const_name] = ['type' => 'const', 'file' => $path];
}
}
// Check global functions for @decorator
if (!empty($metadata['global_functions_with_decorators'])) {
foreach ($metadata['global_functions_with_decorators'] as $func_name => $func_data) {
$decorators = $func_data['decorators'] ?? [];
$line = $func_data['line'] ?? 0;
foreach ($decorators as $decorator) {
$decorator_name = $decorator['name'] ?? $decorator[0] ?? 'unknown';
if ($decorator_name === 'decorator') {
$this->decorator_whitelist[$func_name] = true;
} else {
// Global function with non-@decorator decorator
$this->throw_global_function_decorator($func_name, $path, $line, $decorator_name);
}
}
}
}
// Check static methods for @decorator
if (!empty($metadata['public_static_methods'])) {
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name === 'decorator') {
$this->decorator_whitelist[$method_name] = true;
}
}
}
}
}
// Check instance methods for @decorator
if (!empty($metadata['public_instance_methods'])) {
foreach ($metadata['public_instance_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name === 'decorator') {
$this->decorator_whitelist[$method_name] = true;
}
}
}
}
}
}
// Step 2: Validate static and instance method decorators against whitelist
foreach ($files as $path => $metadata) {
// Skip non-JavaScript files
if (($metadata['extension'] ?? '') !== 'js') {
continue;
}
$class_name = $metadata['class'] ?? 'Unknown';
// Check static methods
if (!empty($metadata['public_static_methods'])) {
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name !== 'decorator' && !isset($this->decorator_whitelist[$decorator_name])) {
$this->throw_unwhitelisted_decorator($decorator_name, $class_name, $method_name, $path);
}
}
}
}
}
// Check instance methods
if (!empty($metadata['public_instance_methods'])) {
foreach ($metadata['public_instance_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name !== 'decorator' && !isset($this->decorator_whitelist[$decorator_name])) {
$this->throw_unwhitelisted_decorator($decorator_name, $class_name, $method_name, $path);
}
}
}
}
}
}
}
// Note: Duplicate/conflict checking methods removed
// This functionality is now handled by BundleCompiler::_check_js_naming_conflicts()
// which checks only the files being bundled, not all files in the project
private function throw_global_function_decorator(string $func_name, string $path, int $line, string $decorator_name): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Global function cannot use decorator\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$path}\n";
$error_message .= "Line: {$line}\n";
$error_message .= "Function: {$func_name}\n";
$error_message .= "Decorator: @{$decorator_name}\n\n";
$error_message .= "Global functions may only use the @decorator marker.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "The function '{$func_name}' has decorator '@{$decorator_name}'.\n";
$error_message .= "Only the '@decorator' marker is allowed on global functions.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " @memoize\n";
$error_message .= " function myFunction() { ... }\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " @decorator\n";
$error_message .= " function myDecorator(target, key, descriptor) { ... }\n\n";
$error_message .= "WHY THIS RESTRICTION:\n";
$error_message .= "- Global functions are processed differently than class methods\n";
$error_message .= "- The @decorator marker identifies decorator implementations\n";
$error_message .= "- Other decorators can only be used on static class methods\n\n";
$error_message .= "FIX OPTIONS:\n";
$error_message .= "1. Remove the '@{$decorator_name}' decorator\n";
$error_message .= "2. Move the function into a class as a static method\n";
$error_message .= "3. If this IS a decorator implementation, use '@decorator' instead\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
private function throw_unwhitelisted_decorator(string $decorator_name, string $class_name, string $method_name, string $path): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Decorator not whitelisted\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$path}\n";
$error_message .= "Class: {$class_name}\n";
$error_message .= "Method: {$method_name}\n";
$error_message .= "Decorator: @{$decorator_name}\n\n";
$error_message .= "The decorator '@{$decorator_name}' is not whitelisted.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "Only decorators marked with @decorator can be used.\n";
$error_message .= "The '{$decorator_name}' function/method needs @decorator marker.\n\n";
$error_message .= "EXAMPLE OF WHITELISTING:\n";
$error_message .= " // Mark the decorator implementation:\n";
$error_message .= " @decorator\n";
$error_message .= " function {$decorator_name}(target, key, descriptor) {\n";
$error_message .= " // Decorator implementation\n";
$error_message .= " return descriptor;\n";
$error_message .= " }\n\n";
$error_message .= " // Or in a class:\n";
$error_message .= " class Decorators {\n";
$error_message .= " @decorator\n";
$error_message .= " static {$decorator_name}(target, key, descriptor) {\n";
$error_message .= " return descriptor;\n";
$error_message .= " }\n";
$error_message .= " }\n\n";
$error_message .= "WHY THIS MATTERS:\n";
$error_message .= "- Decorators must be explicitly whitelisted\n";
$error_message .= "- This prevents typos and undefined decorators\n";
$error_message .= "- Ensures decorators are properly implemented\n\n";
$error_message .= "FIX:\n";
$error_message .= "Add @decorator to the '{$decorator_name}' function/method definition.\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-DEFENSIVE-01';
}
public function get_name(): string
{
return 'JavaScript Defensive Coding Check';
}
public function get_description(): string
{
return 'Prohibits existence checks - code must fail loudly if dependencies are missing';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript file for defensive coding violations (from line 833)
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get sanitized content
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Pattern 1: typeof variable checks (!== undefined, === undefined, == 'function', etc.)
// Match: typeof SomeVar !== 'undefined' or typeof SomeVar == 'function'
if (preg_match('/typeof\s+(\w+)\s*([!=]=+)\s*[\'"]?(undefined|function)[\'"]?/i', $line, $matches)) {
$variable = $matches[1];
// Skip if it's a property check (contains dot)
if (!str_contains($variable, '.')) {
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the existence check. Let the code fail if '{$variable}' is not defined.",
'high'
);
}
}
// Pattern 2: typeof window.variable checks
if (preg_match('/typeof\s+window\.(\w+)\s*([!=]=+)\s*[\'"]?undefined[\'"]?/i', $line, $matches)) {
$variable = 'window.' . $matches[1];
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Checking if '{$variable}' exists. All global variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the existence check. Let the code fail if '{$variable}' is not defined.",
'high'
);
}
// Pattern 3: if (variable) or if (!variable) existence checks (more careful pattern)
// Only match simple variables, not property access
if (preg_match('/if\s*\(\s*(!)?(\w+)\s*\)/', $line, $matches)) {
$variable = $matches[2];
// Skip if it's a property or array access or a boolean-like variable name
if (!str_contains($line, '.' . $variable) &&
!str_contains($line, '[' . $variable) &&
!str_contains($line, $variable . '.') &&
!str_contains($line, $variable . '[') &&
!preg_match('/^(is|has|can|should|will|did|was)[A-Z]/', $variable) && // Skip boolean-named vars
!in_array(strtolower($variable), ['true', 'false', 'null', 'undefined'])) { // Skip literals
// Check if this looks like an existence check by looking at context
if (preg_match('/if\s*\(\s*(!)?typeof\s+' . preg_quote($variable, '/') . '/i', $line) ||
preg_match('/if\s*\(\s*' . preg_quote($variable, '/') . '\s*&&\s*' . preg_quote($variable, '/') . '\./i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the existence check. Let the code fail if '{$variable}' is not defined.",
'high'
);
}
}
}
// Pattern 4: Guard clauses like: Rsx && Rsx.method()
if (preg_match('/(\w+)\s*&&\s*\1\.\w+/i', $line, $matches)) {
$variable = $matches[1];
// Skip common boolean variable patterns
if (!preg_match('/^(is|has|can|should|will|did|was)[A-Z]/', $variable)) {
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Guard clause checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the guard clause. Use '{$variable}.method()' directly.",
'high'
);
}
}
// Pattern 5: try/catch used for existence checking (simplified detection)
if (preg_match('/try\s*\{.*?(\w+).*?\}\s*catch/i', $line, $matches)) {
// This is a simplified check - in reality you'd need multi-line parsing
// Skip for now as it's complex to detect intent
}
}
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class DirectAjaxApi_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-AJAX-01';
}
public function get_name(): string
{
return 'Direct AJAX API Call Check';
}
public function get_description(): string
{
return "Detects direct $.ajax calls to /_ajax/ endpoints instead of using JS controller stubs";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript files for direct $.ajax calls to /_ajax/ endpoints
* Instead of:
* await $.ajax({ url: '/_ajax/Controller/action', ... })
* Should use:
* await Controller.action(params)
* Or:
* await Ajax.call('Controller', 'action', params)
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip Ajax.js itself
if (str_ends_with($file_path, '/Ajax.js')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check files in rsx/ or app/RSpade/
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
// Pattern to match $.ajax({ url: '/_ajax/Controller/action'
// This handles both single-line and multi-line cases
$full_content = implode("\n", $lines);
// Match $.ajax({ with optional whitespace/newlines, then url: with quotes around /_ajax/
// Capture controller and action names for suggestion
$pattern = '/\$\.ajax\s*\(\s*\{[^}]*?url\s*:\s*[\'"](\/_ajax\/([A-Za-z_][A-Za-z0-9_]*)\/([A-Za-z_][A-Za-z0-9_]*))[^\'"]*[\'"]/s';
if (preg_match_all($pattern, $full_content, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$matched_text = $match[0];
$offset = $match[1];
$url = $matches[1][$index][0];
$controller = $matches[2][$index][0];
$action = $matches[3][$index][0];
// Find line number
$line_number = substr_count(substr($full_content, 0, $offset), "\n") + 1;
// Get the actual line for display
$line = $lines[$line_number - 1] ?? '';
// Build suggestion
$suggestion = "Instead of direct $.ajax() call to '{$url}', use:\n";
$suggestion .= " 1. Preferred: await {$controller}.{$action}(params)\n";
$suggestion .= " 2. Alternative: await Ajax.call('{$controller}', '{$action}', params)\n";
$suggestion .= "The JS stub handles session expiry, notifications, and response unwrapping.";
$this->add_violation(
$file_path,
$line_number,
"Direct $.ajax() call to internal API endpoint '{$url}' detected. Use JS controller stub instead.",
trim($line),
$suggestion,
'high'
);
}
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\InitializationSuggestions;
class DocumentReady_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-READY-01';
}
public function get_name(): string
{
return 'JavaScript Document Ready Check';
}
public function get_description(): string
{
return 'Enforces use of ES6 class lifecycle methods instead of window.onload or jQuery ready';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Whether this rule is called during manifest scan
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* document ready patterns prevent the framework's auto-initialization from functioning correctly.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update to extract document ready violations
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return null;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return null;
}
$lines = explode("\n", $contents);
$violations = [];
// Also get sanitized content to skip comments
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments using sanitized version
if (isset($sanitized_lines[$line_num])) {
$sanitized_trimmed = trim($sanitized_lines[$line_num]);
if (empty($sanitized_trimmed)) {
continue; // Skip empty/comment lines
}
}
// Check for window.onload patterns
if (preg_match('/\bwindow\s*\.\s*onload\s*=/', $line)) {
$violations[] = [
'type' => 'window_onload',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need first violation
}
// Check for various jQuery ready patterns and DOMContentLoaded
// Patterns: $().ready, $(document).ready, $("document").ready, $('document').ready, $(function(), DOMContentLoaded
$jquery_ready_patterns = [
'/\$\s*\(\s*\)\s*\.\s*ready\s*\(/', // $().ready(
'/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', // $(document).ready( with spaces
'/\$\s*\(\s*["\']document["\']\s*\)\s*\.\s*ready\s*\(/', // $("document").ready( or $('document').ready(
'/\$\s*\(\s*function\s*\(/', // $(function() - shorthand for $(document).ready
'/jQuery\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', // jQuery(document).ready(
'/jQuery\s*\(\s*["\']document["\']\s*\)\s*\.\s*ready\s*\(/', // jQuery("document").ready( or jQuery('document').ready(
'/jQuery\s*\(\s*function\s*\(/', // jQuery(function() - shorthand
'/document\s*\.\s*addEventListener\s*\(\s*["\']DOMContentLoaded[\"\']/', // document.addEventListener("DOMContentLoaded" or 'DOMContentLoaded'
];
foreach ($jquery_ready_patterns as $pattern) {
if (preg_match($pattern, $line)) {
$violations[] = [
'type' => 'jquery_ready',
'line' => $line_number,
'code' => trim($line)
];
break; // Only report once per line
}
}
// Stop after first violation
if (!empty($violations)) {
break;
}
}
if (!empty($violations)) {
return ['document_ready_violations' => $violations];
}
return null;
}
/**
* Check JavaScript file for document ready violations stored in metadata
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Check for violations in code quality metadata
if (isset($metadata['code_quality_metadata']['JS-READY-01']['document_ready_violations'])) {
$violations = $metadata['code_quality_metadata']['JS-READY-01']['document_ready_violations'];
// Get appropriate suggestion based on code location
$suggestion = InitializationSuggestions::get_suggestion($file_path);
// Throw on first violation
foreach ($violations as $violation) {
$type = $violation['type'];
$line = $violation['line'];
$code = $violation['code'];
if ($type === 'window_onload') {
$error_message = "Code Quality Violation (JS-READY-01) - Prohibited Window Onload Pattern\n\n";
$error_message .= "window.onload is not allowed. Use ES6 class with lifecycle methods instead.\n\n";
} else {
$error_message = "Code Quality Violation (JS-READY-01) - Prohibited jQuery Ready Pattern\n\n";
$error_message .= "jQuery ready/DOMContentLoaded patterns are not allowed. Use ES6 class with lifecycle methods instead.\n\n";
}
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$line}\n";
$error_message .= "Code: {$code}\n\n";
$error_message .= $suggestion;
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($file_path),
$line
);
}
}
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class DomMethod_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-DOM-01';
}
public function get_name(): string
{
return 'JavaScript DOM Method Usage Check';
}
public function get_description(): string
{
return 'Enforces jQuery instead of native DOM methods';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Check for document.getElementById
if (preg_match('/\bdocument\.getElementById\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.getElementById(id)' with '$('#' + id)' or use a jQuery selector directly like $('#myId'). " .
"jQuery provides a more consistent and powerful API for DOM manipulation that works across all browsers.",
'medium'
);
}
// Check for document.createElement
if (preg_match('/\bdocument\.createElement\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.createElement(tagName)' with '$('<' + tagName + '>')' or use jQuery element creation like $('<div>'). " .
"jQuery provides a more fluent API for creating and manipulating DOM elements.",
'medium'
);
}
// Check for document.getElementsByClassName
if (preg_match('/\bdocument\.getElementsByClassName\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.getElementsByClassName(className)' with $('.' + className) or use a jQuery class selector directly like $('.myClass'). " .
"jQuery provides a more consistent API that returns a jQuery object with many useful methods.",
'medium'
);
}
// Check for document.getElementsByTagName
if (preg_match('/\bdocument\.getElementsByTagName\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.getElementsByTagName(tagName)' with $(tagName) or use a jQuery tag selector like $('div'). " .
"jQuery provides a unified API for element selection.",
'medium'
);
}
// Check for document.querySelector
if (preg_match('/\bdocument\.querySelector\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.querySelector(selector)' with $(selector). " .
"jQuery's selector engine is more powerful and consistent across browsers.",
'medium'
);
}
// Check for document.querySelectorAll
if (preg_match('/\bdocument\.querySelectorAll\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.querySelectorAll(selector)' with $(selector). " .
"jQuery automatically handles collections and provides chainable methods.",
'medium'
);
}
}
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\InitializationSuggestions;
class FrameworkInitialization_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-INIT-FW-01';
}
public function get_name(): string
{
return 'Framework Code Initialization Pattern Check';
}
public function get_description(): string
{
return 'Enforces proper initialization patterns for framework JavaScript code in /app/RSpade directory';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check JavaScript file for proper framework initialization patterns
* Framework code in /app/RSpade should use _on_framework_* methods
* User methods (on_modules_*, on_app_*) are forbidden in framework code
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in /app/RSpade directory
if (!str_contains($file_path, '/app/RSpade/')) {
return;
}
// Check if it's in an allowed subdirectory
if (!$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Get original content for pattern detection
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Also get sanitized content to skip comments
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($original_lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments using sanitized version
if (isset($sanitized_lines[$line_num])) {
$sanitized_trimmed = trim($sanitized_lines[$line_num]);
if (empty($sanitized_trimmed)) {
continue; // Skip empty/comment lines
}
}
// Check for user code methods (forbidden in framework code)
$user_methods = [
'on_modules_define',
'on_modules_init',
'on_app_define',
'on_app_init',
'on_app_ready'
];
foreach ($user_methods as $method) {
if (preg_match('/\bstatic\s+(async\s+)?' . preg_quote($method) . '\s*\(\s*\)/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"User initialization method '{$method}' cannot be used in framework code.",
trim($line),
InitializationSuggestions::get_framework_suggestion(),
'critical'
);
}
}
// Check for Rsx.on('ready') pattern
if (preg_match('/\bRsx\s*\.\s*on\s*\(\s*[\'\"]ready[\'\"]/i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Rsx.on('ready') is deprecated. Use framework lifecycle methods instead.",
trim($line),
InitializationSuggestions::get_framework_suggestion(),
'high'
);
}
// Check for jQuery ready patterns (should not be in framework code)
if (preg_match('/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line) ||
preg_match('/\$\s*\(\s*function\s*\(/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"jQuery ready patterns are not allowed in framework code. Use framework lifecycle methods.",
trim($line),
InitializationSuggestions::get_framework_suggestion(),
'high'
);
}
// Validate correct framework method usage (informational)
$framework_methods = [
'_on_framework_core_define',
'_on_framework_core_init',
'_on_framework_module_define',
'_on_framework_module_init'
];
foreach ($framework_methods as $method) {
if (preg_match('/\bstatic\s+(async\s+)?' . preg_quote($method) . '\s*\(\s*\)/', $line)) {
// This is correct usage - no violation
// Could log this for validation purposes if needed
}
}
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\InitializationSuggestions;
class InitializationPattern_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-INIT-USER-01';
}
public function get_name(): string
{
return 'User Code Initialization Pattern Check';
}
public function get_description(): string
{
return 'Enforces proper initialization patterns for user JavaScript code in /rsx directory';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript file for proper initialization patterns
* User code in /rsx should use on_modules_* or on_app_* methods
* Framework methods (_on_framework_*) are forbidden in user code
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in /rsx directory
if (!str_contains($file_path, '/rsx/')) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Get original content for pattern detection
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Also get sanitized content to skip comments
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($original_lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments using sanitized version
if (isset($sanitized_lines[$line_num])) {
$sanitized_trimmed = trim($sanitized_lines[$line_num]);
if (empty($sanitized_trimmed)) {
continue; // Skip empty/comment lines
}
}
// Check for Rsx.on('ready') pattern
if (preg_match('/\bRsx\s*\.\s*on\s*\(\s*[\'"]ready[\'"]/i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Rsx.on('ready') is deprecated. Use ES6 class lifecycle methods instead.",
trim($line),
InitializationSuggestions::get_user_suggestion(),
'high'
);
}
// Check for framework methods (forbidden in user code)
$framework_methods = [
'_on_framework_core_define',
'_on_framework_core_init',
'_on_framework_module_define',
'_on_framework_module_init'
];
foreach ($framework_methods as $method) {
if (preg_match('/\bstatic\s+(async\s+)?' . preg_quote($method) . '\s*\(\s*\)/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Framework initialization method '{$method}' cannot be used in user code.",
trim($line),
InitializationSuggestions::get_user_suggestion(),
'critical'
);
}
}
// Check for jQuery ready patterns (handled by DocumentReadyRule but add context here)
if (preg_match('/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line) ||
preg_match('/\$\s*\(\s*function\s*\(/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"jQuery ready patterns are not allowed. Use ES6 class lifecycle methods.",
trim($line),
InitializationSuggestions::get_user_suggestion(),
'high'
);
}
}
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQueryLengthCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-LENGTH-01';
}
public function get_name(): string
{
return 'jQuery .length Existence Check';
}
public function get_description(): string
{
return 'Enforces use of .exists() instead of .length for jQuery existence checks';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
// Patterns to detect:
// if($(selector).length)
// if(!$(selector).length)
// if($variable.length)
// if(!$variable.length)
// Also within compound conditions
// Check if line contains 'if' and '.length'
if (str_contains($sanitized_line, 'if') && str_contains($sanitized_line, '.length')) {
// Multiple patterns to check
$patterns = [
// Direct jQuery selector patterns
'/if\s*\(\s*!\s*\$\s*\([^)]+\)\.length/', // if(!$(selector).length
'/if\s*\(\s*\$\s*\([^)]+\)\.length/', // if($(selector).length
// jQuery variable patterns
'/if\s*\(\s*!\s*\$[a-zA-Z_][a-zA-Z0-9_]*\.length/', // if(!$variable.length
'/if\s*\(\s*\$[a-zA-Z_][a-zA-Z0-9_]*\.length/', // if($variable.length
// Within compound conditions (with && or ||)
'/if\s*\([^)]*[&|]{2}[^)]*\$\s*\([^)]+\)\.length/', // compound with $(selector).length
'/if\s*\([^)]*\$\s*\([^)]+\)\.length[^)]*[&|]{2}/', // compound with $(selector).length
'/if\s*\([^)]*[&|]{2}[^)]*\$[a-zA-Z_][a-zA-Z0-9_]*\.length/', // compound with $variable.length
'/if\s*\([^)]*\$[a-zA-Z_][a-zA-Z0-9_]*\.length[^)]*[&|]{2}/', // compound with $variable.length
];
$found = false;
foreach ($patterns as $pattern) {
if (preg_match($pattern, $sanitized_line)) {
$found = true;
break;
}
}
if ($found) {
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$this->add_violation(
$file_path,
$line_number,
"Use .exists() instead of .length for jQuery existence checks.",
trim($original_line),
"Replace .length with .exists() for checking jQuery element existence. " .
"For example: use '$(selector).exists()' instead of '$(selector).length', " .
"or '\$variable.exists()' instead of '\$variable.length'. " .
"The .exists() method is more semantic and clearly indicates the intent of checking for element presence.",
'medium'
);
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQuerySubmitUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-JQUERY-SUBMIT-01';
}
public function get_name(): string
{
return 'jQuery .submit() Usage Check';
}
public function get_description(): string
{
return "Detects deprecated jQuery .submit() usage and recommends .trigger('submit') or .requestSubmit()";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'low';
}
/**
* Check JavaScript file for .submit() usage
* Recommends .trigger('submit') or .requestSubmit() instead
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Remove comments and strings to avoid false positives
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized = $sanitized_data['content'];
// Pattern matches:
// $variable.submit()
// $(selector).submit()
// that.$anything.submit()
// this.$anything.submit()
$pattern = '/(\$[a-zA-Z_][a-zA-Z0-9_]*|(?:this|that)\.\$[a-zA-Z0-9_.]+|\$\([^)]+\))\.submit\s*\(/';
preg_match_all($pattern, $sanitized, $matches, PREG_OFFSET_CAPTURE);
if (empty($matches[0])) {
return;
}
$lines = explode("\n", $contents);
foreach ($matches[0] as $match) {
$offset = $match[1];
$matched_text = $match[0];
// Find line number from offset
$line_number = 1;
$current_offset = 0;
foreach ($lines as $index => $line) {
$line_length = strlen($line) + 1; // +1 for newline
if ($current_offset + $line_length > $offset) {
$line_number = $index + 1;
break;
}
$current_offset += $line_length;
}
$code_snippet = trim($lines[$line_number - 1] ?? '');
$this->add_violation(
$file_path,
$line_number,
"Use .trigger('submit') or .requestSubmit() instead of deprecated .submit()",
$code_snippet,
"Replace .submit() with .trigger('submit') for event triggering or .requestSubmit() for actual form submission with validation",
'low'
);
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQueryUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-JQUERY-01';
}
public function get_name(): string
{
return 'JavaScript jQuery Usage Check';
}
public function get_description(): string
{
return "Enforces use of '$' shorthand instead of 'jQuery' for consistency";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'low';
}
/**
* Check JavaScript file for 'jQuery' usage instead of '$' (from line 1307)
* Enforces use of '$' shorthand for consistency
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Check for 'jQuery.' or 'jQuery(' usage
if (preg_match('/\bjQuery\s*[\.\(]/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Use '$' instead of 'jQuery' for consistency and brevity.",
trim($line),
"Replace 'jQuery' with '$'.",
'low'
);
}
}
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQueryVariableNaming_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-JQUERY-VAR-01';
}
public function get_name(): string
{
return 'jQuery Variable Naming Convention';
}
public function get_description(): string
{
return 'Enforces $ prefix for variables storing jQuery objects';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* jQuery methods that return jQuery objects
*/
private const JQUERY_OBJECT_METHODS = [
'parent', 'parents', 'parentsUntil', 'closest',
'find', 'children', 'contents',
'next', 'nextAll', 'nextUntil',
'prev', 'prevAll', 'prevUntil',
'siblings', 'add', 'addBack', 'andSelf',
'end', 'filter', 'not', 'has',
'eq', 'first', 'last', 'slice',
'map', 'clone', 'wrap', 'wrapAll', 'wrapInner',
'unwrap', 'replaceWith', 'replaceAll',
'prepend', 'append', 'prependTo', 'appendTo',
'before', 'after', 'insertBefore', 'insertAfter',
'detach', 'empty', 'remove'
];
/**
* jQuery methods that return scalar values (not jQuery objects)
*/
private const SCALAR_METHODS = [
'data', 'attr', 'val', 'text', 'html', 'prop', 'css',
'offset', 'position', 'scrollTop', 'scrollLeft',
'width', 'height', 'innerWidth', 'innerHeight',
'outerWidth', 'outerHeight',
'index', 'size', 'length', 'get', 'toArray',
'serialize', 'serializeArray',
'is', 'hasClass', 'is_visible' // Custom RSpade methods
];
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Pattern to match variable assignments
// Captures: 1=var declaration, 2=variable name, 3=right side expression
$pattern = '/(?:^|\s)((?:let\s+|const\s+|var\s+)?)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(.+?)(?:;|$)/';
if (preg_match($pattern, $sanitized_line, $matches)) {
$var_decl = $matches[1];
$var_name = $matches[2];
$right_side = trim($matches[3]);
$has_dollar = str_starts_with($var_name, '$');
// Analyze the right side to determine if it returns jQuery object or scalar
$expected_type = $this->analyze_expression($right_side);
if ($expected_type === 'jquery') {
// Should have $ prefix
if (!$has_dollar) {
$this->add_violation(
$file_path,
$line_number,
"jQuery object must be stored in variable starting with $.",
trim($original_line),
"Rename variable '{$var_name}' to '\${$var_name}'. " .
"The expression returns a jQuery object and must be stored in a variable with $ prefix. " .
"In RSpade, $ prefix indicates jQuery objects only.",
'medium'
);
}
} elseif ($expected_type === 'scalar') {
// Should NOT have $ prefix
if ($has_dollar) {
$this->add_violation(
$file_path,
$line_number,
"Scalar values should not use $ prefix.",
trim($original_line),
"Remove $ prefix from variable '{$var_name}'. Rename to '" . substr($var_name, 1) . "'. " .
"The expression returns a scalar value (string, number, boolean, or DOM element), not a jQuery object. " .
"In RSpade, $ prefix is reserved for jQuery objects only.",
'medium'
);
}
}
// If expected_type is 'unknown', we don't enforce either way
}
}
}
/**
* Analyze an expression to determine if it returns jQuery object or scalar
* @return string 'jquery', 'scalar', or 'unknown'
*/
private function analyze_expression(string $expr): string
{
$expr = trim($expr);
// Direct jQuery selector: $(...)
if (preg_match('/^\$\s*\(/', $expr)) {
// Check if followed by method chain
if (preg_match('/^\$\s*\([^)]*\)(.*)/', $expr, $matches)) {
$chain = trim($matches[1]);
if ($chain === '') {
return 'jquery'; // Just $(...) with no methods
}
return $this->analyze_method_chain($chain);
}
return 'jquery';
}
// Variable starting with $ (assumed to be jQuery)
if (preg_match('/^\$[a-zA-Z_][a-zA-Z0-9_]*(.*)/', $expr, $matches)) {
$chain = trim($matches[1]);
if ($chain === '') {
return 'jquery'; // Just $variable with no methods
}
if (str_starts_with($chain, '[')) {
// Array access like $element[0]
return 'scalar';
}
return $this->analyze_method_chain($chain);
}
// Everything else is unknown or definitely not jQuery
return 'unknown';
}
/**
* Analyze a method chain to determine final return type
* @param string $chain The method chain starting with . or [
* @return string 'jquery', 'scalar', or 'unknown'
*/
private function analyze_method_chain(string $chain): string
{
if (empty($chain)) {
return 'jquery'; // No methods means original jQuery object
}
// Array access [0] or [index] returns DOM element (scalar)
if (preg_match('/^\[[\d]+\]/', $chain)) {
return 'scalar';
}
// Find the last method call in the chain
// Match patterns like .method() or .method(args)
$methods = [];
preg_match_all('/\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)/', $chain, $methods);
if (empty($methods[1])) {
// No method calls found
return 'unknown';
}
// Check the last method to determine return type
$last_method = end($methods[1]);
if (in_array($last_method, self::JQUERY_OBJECT_METHODS, true)) {
return 'jquery';
}
if (in_array($last_method, self::SCALAR_METHODS, true)) {
return 'scalar';
}
// Unknown method - could be custom plugin
return 'unknown';
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class JQueryVisibilityCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-VISIBLE-01';
}
public function get_name(): string
{
return 'jQuery .is(\':visible\') Check';
}
public function get_description(): string
{
return 'Enforces use of .is_visible() instead of .is(\':visible\') for jQuery visibility checks';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get original content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Pattern to match .is(':visible') or .is(":visible")
$pattern = '/\.is\s*\(\s*[\'\"]:visible[\'\"]\s*\)/';
foreach ($original_lines as $line_num => $original_line) {
$line_number = $line_num + 1;
// Skip empty lines
if (trim($original_line) === '') {
continue;
}
// Check if line contains .is(':visible') pattern
if (preg_match($pattern, $original_line, $matches, PREG_OFFSET_CAPTURE)) {
$match_position = $matches[0][1];
// Check if there's a // comment before the match
$comment_pos = strpos($original_line, '//');
if ($comment_pos !== false && $comment_pos < $match_position) {
// The match is in a comment, skip it
continue;
}
// Check if the match is inside a /* */ comment block (simplified check)
// This is a basic check - for full accuracy would need to track multi-line comments
$before_match = substr($original_line, 0, $match_position);
if (str_contains($before_match, '/*') && !str_contains($before_match, '*/')) {
// Likely inside a block comment
continue;
}
$this->add_violation(
$file_path,
$line_number,
"Use .is_visible() instead of .is(':visible') for jQuery visibility checks.",
trim($original_line),
"Replace .is(':visible') with .is_visible() for checking jQuery element visibility. " .
"IMPORTANT: .is_visible() is a custom jQuery extension implemented in the RSpade framework " .
"and is available throughout the codebase. It provides better performance and clearer intent " .
"than the :visible selector. Example: use '$(selector).is_visible()' instead of '$(selector).is(':visible')'.",
'medium'
);
}
}
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
use App\RSpade\Core\Cache\RsxCache;
/**
* Check Jqhtml_Component implementations for common AI agent mistakes
* Validates that components follow correct patterns
*/
class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JQHTML-IMPL-01';
}
public function get_name(): string
{
return 'Jqhtml Component Implementation Check';
}
public function get_description(): string
{
return 'Validates Jqhtml_Component subclasses follow correct patterns';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
/**
* Run during manifest build for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a JavaScript file
if (!isset($metadata['extension']) || $metadata['extension'] !== 'js') {
return;
}
// Skip if not a Jqhtml_Component subclass
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Jqhtml_Component') {
return;
}
// Check cache to avoid redundant validation
$cache_key = $metadata['hash'] ?? md5($contents);
if (RsxCache::get_persistent($cache_key, false) === true) {
// Already validated
return;
}
$lines = explode("\n", $contents);
// Check for render() method and incorrect lifecycle methods
foreach ($lines as $line_num => $line) {
$trimmed = trim($line);
// Check for render() method
if (preg_match('/^render\s*\(/', $trimmed)) {
$this->throw_render_method_error($file_path, $line_num + 1, $metadata['class'] ?? 'Unknown');
}
// Check for incorrect event method names (create, load, ready without on_ prefix)
if (preg_match('/^(create|load|ready)\s*\(/', $trimmed, $matches)) {
$method = $matches[1];
$this->throw_lifecycle_method_error($file_path, $line_num + 1, $method);
}
}
// Mark as validated in cache
RsxCache::set_persistent($cache_key, true);
}
private function throw_render_method_error(string $file_path, int $line_number, string $class_name): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Jqhtml component should not have render() method\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$line_number}\n";
$error_message .= "Class: {$class_name}\n\n";
$error_message .= "Jqhtml components should not define a render() method.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "The render() method is not part of the Jqhtml_Component lifecycle.\n";
$error_message .= "Jqhtml components use template files (.jqhtml) for rendering.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " render() {\n";
$error_message .= " return '<div>...</div>';\n";
$error_message .= " }\n";
$error_message .= " }\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " // Create a template file: my_component.jqhtml\n";
$error_message .= " <div>\n";
$error_message .= " <%= content() %>\n";
$error_message .= " </div>\n\n";
$error_message .= " // JavaScript class handles logic:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " on_ready() {\n";
$error_message .= " // Component logic here\n";
$error_message .= " }\n";
$error_message .= " }\n\n";
$error_message .= "WHY THIS MATTERS:\n";
$error_message .= "- Jqhtml separates template from logic\n";
$error_message .= "- Templates are pre-compiled for performance\n";
$error_message .= "- The render() pattern is from React, not Jqhtml\n\n";
$error_message .= "FIX:\n";
$error_message .= "1. Remove the render() method\n";
$error_message .= "2. Create a .jqhtml template file for the component\n";
$error_message .= "3. Use lifecycle methods like on_create(), on_load(), on_ready()\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
private function throw_lifecycle_method_error(string $file_path, int $line_number, string $method_name): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Jqhtml lifecycle method missing 'on_' prefix\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$line_number}\n";
$error_message .= "Method: {$method_name}()\n\n";
$error_message .= "Jqhtml lifecycle methods must use the 'on_' prefix.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "The method '{$method_name}()' should be 'on_{$method_name}()'.\n";
$error_message .= "Jqhtml components use specific lifecycle method names.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " create() { ... } // Wrong\n";
$error_message .= " load() { ... } // Wrong\n";
$error_message .= " ready() { ... } // Wrong\n";
$error_message .= " }\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " on_create() { ... } // Correct\n";
$error_message .= " on_load() { ... } // Correct\n";
$error_message .= " on_ready() { ... } // Correct\n";
$error_message .= " }\n\n";
$error_message .= "LIFECYCLE METHODS:\n";
$error_message .= "- on_create(): Called when component is created\n";
$error_message .= "- on_load(): Called to load async data\n";
$error_message .= "- on_ready(): Called when component is ready in DOM\n";
$error_message .= "- on_destroy(): Called when component is destroyed\n\n";
$error_message .= "FIX:\n";
$error_message .= "Rename '{$method_name}()' to 'on_{$method_name}()'\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class JqhtmlDataInCreate_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-JQHTML-01';
}
public function get_name(): string
{
return 'Jqhtml Component this.data in on_create() Check';
}
public function get_description(): string
{
return 'Ensures this.data is not used in on_create() method of Jqhtml components';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for improper this.data usage in on_create() methods of Jqhtml components
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get violations from AST parser (with caching)
$violations = $this->parse_with_acorn($file_path);
if (empty($violations)) {
return;
}
// Process violations
foreach ($violations as $violation) {
$line_number = $violation['line'];
$class_name = $violation['className'] ?? 'unknown';
$code_snippet = $violation['codeSnippet'] ?? 'this.data';
$this->add_violation(
$file_path,
$line_number,
"Jqhtml Component Error: 'this.data' used in on_create() method of class '{$class_name}'. " .
"The 'this.data' property is only available during on_load() and later lifecycle steps. " .
"It is used to store data fetched from AJAX or other async operations.",
$code_snippet,
"Use 'this.args' instead to access the parameters passed to the component at creation time. " .
"The args contain attributes from the component's invocation in templates or JavaScript. " .
"Example: Change 'this.data.initial_value' to 'this.args.initial_value'.",
'high'
);
}
}
/**
* Parse JavaScript file with acorn AST parser
* Results are cached based on file modification time
*/
protected function parse_with_acorn(string $file_path): array
{
// Create cache directory if needed
$cache_dir = storage_path('rsx-tmp/persistent/code-quality-jqhtml-data');
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0777, true);
}
// Generate cache key based on file path and mtime
$file_mtime = filemtime($file_path);
$file_size = filesize($file_path);
$cache_key = md5($file_path . ':jqhtml-data');
$cache_file = $cache_dir . '/' . $cache_key . '.json';
// Check if cached result exists and is valid
if (file_exists($cache_file)) {
$cache_data = json_decode(file_get_contents($cache_file), true);
if ($cache_data &&
isset($cache_data['mtime']) && $cache_data['mtime'] == $file_mtime &&
isset($cache_data['size']) && $cache_data['size'] == $file_size) {
// Cache is valid
return $cache_data['violations'] ?? [];
}
}
// Create parser script if it doesn't exist
$parser_script = storage_path('rsx-tmp/persistent/parse-jqhtml-data.js');
if (!file_exists($parser_script)) {
$this->create_parser_script($parser_script);
}
// Run parser
$command = sprintf(
'node %s %s 2>&1',
escapeshellarg($parser_script),
escapeshellarg($file_path)
);
$output = shell_exec($command);
if (!$output) {
return [];
}
$result = json_decode($output, true);
if (!$result || !isset($result['violations'])) {
// Parser error - don't cache
return [];
}
// Cache the result
$cache_data = [
'mtime' => $file_mtime,
'size' => $file_size,
'violations' => $result['violations']
];
file_put_contents($cache_file, json_encode($cache_data));
return $result['violations'];
}
/**
* Create the Node.js parser script
*/
protected function create_parser_script(string $script_path): void
{
$dir = dirname($script_path);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$script_content = <<<'JAVASCRIPT'
#!/usr/bin/env node
const fs = require('fs');
const acorn = require('acorn');
const walk = require('acorn-walk');
// Classes that are Jqhtml components
const JQHTML_COMPONENTS = new Set([
'Jqhtml_Component', '_Base_Jqhtml_Component', 'Component'
]);
function analyzeFile(filePath) {
const code = fs.readFileSync(filePath, 'utf8');
const lines = code.split('\n');
let ast;
try {
ast = acorn.parse(code, {
ecmaVersion: 2020,
sourceType: 'module',
locations: true
});
} catch (e) {
// Parse error - return empty violations
console.log(JSON.stringify({ violations: [] }));
return;
}
const violations = [];
let currentClass = null;
let inOnCreate = false;
// Helper to check if a class extends Jqhtml_Component
function isJqhtmlComponent(extendsClass) {
if (!extendsClass) return false;
return JQHTML_COMPONENTS.has(extendsClass) ||
extendsClass.includes('Component') ||
extendsClass.includes('Jqhtml');
}
// Walk the AST
walk.simple(ast, {
ClassDeclaration(node) {
currentClass = {
name: node.id.name,
extends: node.superClass?.name,
isJqhtml: isJqhtmlComponent(node.superClass?.name)
};
},
ClassExpression(node) {
currentClass = {
name: node.id?.name || 'anonymous',
extends: node.superClass?.name,
isJqhtml: isJqhtmlComponent(node.superClass?.name)
};
},
MethodDefinition(node) {
// Check if this is on_create method
if (node.key.name === 'on_create' && currentClass?.isJqhtml) {
inOnCreate = true;
// Walk the method body looking for this.data
walk.simple(node.value.body, {
MemberExpression(memberNode) {
// Check for this.data pattern
if (memberNode.object.type === 'ThisExpression' &&
memberNode.property.name === 'data') {
// Found this.data in on_create
const lineContent = lines[memberNode.loc.start.line - 1] || '';
violations.push({
line: memberNode.loc.start.line,
column: memberNode.loc.start.column,
className: currentClass.name,
codeSnippet: lineContent.trim()
});
}
}
});
inOnCreate = false;
}
}
});
console.log(JSON.stringify({ violations }));
}
// Main
if (process.argv.length < 3) {
console.error('Usage: node parse-jqhtml-data.js <file-path>');
process.exit(1);
}
try {
analyzeFile(process.argv[2]);
} catch (e) {
console.error('Error:', e.message);
console.log(JSON.stringify({ violations: [] }));
}
JAVASCRIPT;
file_put_contents($script_path, $script_content);
chmod($script_path, 0755);
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\Core\Manifest\Manifest;
class JqhtmlOnLoadData_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JQHTML-LOAD-02';
}
public function get_name(): string
{
return 'JQHTML on_load Data Assignment Check';
}
public function get_description(): string
{
return 'on_load() method should only set this.data properties, not other this. properties';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check that on_load methods only set this.data properties
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get sanitized content
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$content = $sanitized_data['content'];
// Get JavaScript class from manifest metadata
$js_classes = [];
if (isset($metadata['class']) && isset($metadata['extension']) && $metadata['extension'] === 'js') {
$js_classes = [$metadata['class']];
}
// If no classes in metadata, try to extract from source
if (empty($js_classes)) {
return;
}
// Check each class to see if it's a JQHTML component
foreach ($js_classes as $class_name) {
// Use Manifest to check inheritance (handles indirect inheritance)
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
!Manifest::js_is_subclass_of($class_name, 'Component')) {
continue;
}
// Now find WHERE this class is in the source for extraction
// @META-INHERIT-01-EXCEPTION - Finding class position with PREG_OFFSET_CAPTURE to extract method bodies
if (!preg_match('/class\s+' . preg_quote($class_name, '/') . '\s+extends\s+\w+\s*\{/i', $content, $class_match, PREG_OFFSET_CAPTURE)) {
continue;
}
$class_start = $class_match[0][1];
// Find the class content
$brace_count = 0;
$in_class = false;
$class_content = '';
$pos = $class_start;
while ($pos < strlen($content)) {
$char = $content[$pos];
if ($char === '{') {
$brace_count++;
$in_class = true;
} elseif ($char === '}') {
$brace_count--;
if ($brace_count === 0 && $in_class) {
$class_content = substr($content, $class_start, $pos - $class_start + 1);
break;
}
}
$pos++;
}
if (empty($class_content)) {
continue;
}
// Look for on_load method
if (!preg_match('/(?:async\s+)?on_load\s*\([^)]*\)\s*\{/i', $class_content, $method_match, PREG_OFFSET_CAPTURE)) {
continue; // No on_load method
}
$method_start = $method_match[0][1];
// Extract the on_load method body
$method_pos = $class_start + $method_start;
$method_brace_count = 0;
$in_method = false;
$method_content = '';
$pos = $method_pos;
while ($pos < strlen($content)) {
$char = $content[$pos];
if ($char === '{') {
$method_brace_count++;
$in_method = true;
} elseif ($char === '}') {
$method_brace_count--;
if ($method_brace_count === 0 && $in_method) {
$method_content = substr($content, $method_pos, $pos - $method_pos + 1);
break;
}
}
$pos++;
}
if (empty($method_content)) {
continue;
}
// Check for this. property assignments
$lines = explode("\n", $method_content);
$line_offset = substr_count(substr($content, 0, $method_pos), "\n");
foreach ($lines as $line_num => $line) {
$actual_line_number = $line_offset + $line_num + 1;
$trimmed = trim($line);
// Skip comments
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*')) {
continue;
}
// Check for this.property = value patterns
// Match: this.something =
// But not: this.data.something =
if (preg_match('/\bthis\.(\w+)\s*=/', $line, $matches)) {
$property_name = $matches[1];
// Allow this.data assignments
if ($property_name === 'data') {
continue;
}
// Check if it's a sub-property of data (this.data.something)
if (preg_match('/\bthis\.data\.\w+/', $line)) {
continue;
}
// Check for destructuring into this.data
if (preg_match('/\bthis\.data\s*=\s*\{.*' . preg_quote($property_name, '/') . '/', $line)) {
continue;
}
$this->add_violation(
$file_path,
$actual_line_number,
"Setting 'this.{$property_name}' in on_load() method of class '{$class_name}'. The on_load() method should only update this.data properties.",
trim($line),
"Change to 'this.data.{$property_name} = ...' or move to on_create() if it's component state.",
'high'
);
}
// Also check for Object.assign or similar patterns that set this properties
if (preg_match('/Object\.assign\s*\(\s*this\s*(?!\.data)/', $line)) {
$this->add_violation(
$file_path,
$actual_line_number,
"Using Object.assign on 'this' in on_load() method of class '{$class_name}'. The on_load() method should only update this.data.",
trim($line),
"Use 'Object.assign(this.data, ...)' instead, or move to on_create() for component state.",
'high'
);
}
}
}
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\Core\Manifest\Manifest;
class JqhtmlOnLoadDom_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JQHTML-LOAD-01';
}
public function get_name(): string
{
return 'JQHTML on_load DOM Access Check';
}
public function get_description(): string
{
return 'on_load() method must not access DOM or call render() - only update this.data';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for DOM access in on_load methods of Jqhtml_Component subclasses
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get sanitized content
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$content = $sanitized_data['content'];
// Get JavaScript class from manifest metadata
$js_classes = [];
if (isset($metadata['class']) && isset($metadata['extension']) && $metadata['extension'] === 'js') {
$js_classes = [$metadata['class']];
}
// If no classes in metadata, nothing to check
if (empty($js_classes)) {
return;
}
// Check each class to see if it's a JQHTML component
foreach ($js_classes as $class_name) {
// Use Manifest to check inheritance (handles indirect inheritance)
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
!Manifest::js_is_subclass_of($class_name, 'Component')) {
continue;
}
// Now find WHERE this class is in the source for extraction
// @META-INHERIT-01-EXCEPTION - Finding class position with PREG_OFFSET_CAPTURE to extract method bodies
// Not checking inheritance - need PREG_OFFSET_CAPTURE to extract method bodies
if (!preg_match('/class\s+' . preg_quote($class_name, '/') . '\s+extends\s+\w+\s*\{/i', $content, $class_match, PREG_OFFSET_CAPTURE)) {
continue;
}
$class_start = $class_match[0][1];
// Find the class content
$brace_count = 0;
$in_class = false;
$class_content = '';
$pos = $class_start;
while ($pos < strlen($content)) {
$char = $content[$pos];
if ($char === '{') {
$brace_count++;
$in_class = true;
} elseif ($char === '}') {
$brace_count--;
if ($brace_count === 0 && $in_class) {
$class_content = substr($content, $class_start, $pos - $class_start + 1);
break;
}
}
$pos++;
}
if (empty($class_content)) {
continue;
}
// Look for on_load method
if (!preg_match('/(?:async\s+)?on_load\s*\([^)]*\)\s*\{/i', $class_content, $method_match, PREG_OFFSET_CAPTURE)) {
continue; // No on_load method
}
$method_start = $method_match[0][1];
// Extract the on_load method body
$method_pos = $class_start + $method_start;
$method_brace_count = 0;
$in_method = false;
$method_content = '';
$pos = $method_pos;
while ($pos < strlen($content)) {
$char = $content[$pos];
if ($char === '{') {
$method_brace_count++;
$in_method = true;
} elseif ($char === '}') {
$method_brace_count--;
if ($method_brace_count === 0 && $in_method) {
$method_content = substr($content, $method_pos, $pos - $method_pos + 1);
break;
}
}
$pos++;
}
if (empty($method_content)) {
continue;
}
// Check for DOM access patterns
$lines = explode("\n", $method_content);
$line_offset = substr_count(substr($content, 0, $method_pos), "\n");
foreach ($lines as $line_num => $line) {
$actual_line_number = $line_offset + $line_num + 1;
$trimmed = trim($line);
// Skip comments
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*')) {
continue;
}
// Check for this.$ (jQuery element access)
// Match this.$ followed by anything except .ajax/.get/.post/.getJSON
if (preg_match('/\bthis\.\$/', $line)) {
// Check if it's this.$.something (DOM manipulation) vs $.ajax usage
if (preg_match('/\bthis\.\$\./', $line)) {
// this.$. pattern - this is DOM manipulation
$this->add_violation(
$file_path,
$actual_line_number,
"DOM access 'this.\$' detected in on_load() method of class '{$class_name}'. The on_load() phase runs in parallel and must not access DOM.",
trim($line),
"To handle loading states, you have two options:\n" .
"1. Use conditional rendering in your .jqhtml template:\n" .
" <% if (this.data === null): %>\n" .
" <div class=\"loading\">Loading...</div>\n" .
" <% else: %>\n" .
" <div class=\"content\">...loaded content...</div>\n" .
" <% endif; %>\n" .
"2. Set loading state in on_create() (before load) and clear it in on_ready() (after load).\n" .
' on_load() should only update this.data properties.',
'high'
);
}
}
// Check for this.$id() calls
if (preg_match('/\bthis\.\$id\s*\(/', $line)) {
$this->add_violation(
$file_path,
$actual_line_number,
"DOM access 'this.\$id()' detected in on_load() method of class '{$class_name}'. The on_load() phase runs in parallel and must not access DOM.",
trim($line),
"To handle loading states, you have two options:\n" .
"1. Use conditional rendering in your .jqhtml template:\n" .
" <% if (this.data === null): %>\n" .
" <div class=\"loading\">Loading...</div>\n" .
" <% else: %>\n" .
" <div class=\"content\">...loaded content...</div>\n" .
" <% endif; %>\n" .
"2. Set loading state in on_create() (before load) and clear it in on_ready() (after load).\n" .
' on_load() should only update this.data properties.',
'high'
);
}
// Check for jQuery selector usage (but allow $.ajax)
if (preg_match('/\$\s*\([\'"`]/', $line) && !preg_match('/\$\.(ajax|get|post|getJSON)/', $line)) {
$this->add_violation(
$file_path,
$actual_line_number,
"jQuery selector '\$()' detected in on_load() method of class '{$class_name}'. The on_load() phase runs in parallel and must not access DOM.",
trim($line),
"To handle loading states, you have two options:\n" .
"1. Use conditional rendering in your .jqhtml template:\n" .
" <% if (this.data === null): %>\n" .
" <div class=\"loading\">Loading...</div>\n" .
" <% else: %>\n" .
" <div class=\"content\">...loaded content...</div>\n" .
" <% endif; %>\n" .
"2. Set loading state in on_create() (before load) and clear it in on_ready() (after load).\n" .
' on_load() should only update this.data properties. Use $.ajax() for data fetching.',
'high'
);
}
// Check for this.render() calls
if (preg_match('/\bthis\.render\s*\(/', $line)) {
$this->add_violation(
$file_path,
$actual_line_number,
"Calling 'this.render()' in on_load() method of class '{$class_name}' is not allowed. Re-render happens automatically after on_load() if this.data changed.",
trim($line),
'Remove this.render() call. The framework automatically re-renders after on_load() if this.data was modified.',
'high'
);
}
}
}
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\Core\Manifest\Manifest;
class JqhtmlRenderOverride_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JQHTML-RENDER-01';
}
public function get_name(): string
{
return 'JQHTML render() Method Override Check';
}
public function get_description(): string
{
return 'Components must not override render() method - use on_render(), on_create(), or on_ready() instead';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for render() method override in Jqhtml_Component subclasses
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get sanitized content
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$content = $sanitized_data['content'];
// Get JavaScript class from manifest metadata
$js_classes = [];
if (isset($metadata['class']) && isset($metadata['extension']) && $metadata['extension'] === 'js') {
$js_classes = [$metadata['class']];
}
// If no classes in metadata, nothing to check
if (empty($js_classes)) {
return;
}
// Check each class to see if it's a JQHTML component
foreach ($js_classes as $class_name) {
// Use Manifest to check inheritance (handles indirect inheritance)
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
!Manifest::js_is_subclass_of($class_name, 'Component')) {
continue;
}
// Now find WHERE this class is in the source for extraction
// @META-INHERIT-01-EXCEPTION - Finding class position with PREG_OFFSET_CAPTURE to extract method bodies
// Not checking inheritance - need PREG_OFFSET_CAPTURE to extract method bodies
if (!preg_match('/class\s+' . preg_quote($class_name, '/') . '\s+extends\s+\w+\s*\{/i', $content, $class_match, PREG_OFFSET_CAPTURE)) {
continue;
}
$class_start = $class_match[0][1];
// Find the class content
$brace_count = 0;
$in_class = false;
$class_content = '';
$pos = $class_start;
while ($pos < strlen($content)) {
$char = $content[$pos];
if ($char === '{') {
$brace_count++;
$in_class = true;
} elseif ($char === '}') {
$brace_count--;
if ($brace_count === 0 && $in_class) {
$class_content = substr($content, $class_start, $pos - $class_start + 1);
break;
}
}
$pos++;
}
if (empty($class_content)) {
continue;
}
// Check for render() method definition
$lines = explode("\n", $class_content);
$line_offset = substr_count(substr($content, 0, $class_start), "\n");
foreach ($lines as $line_num => $line) {
$actual_line_number = $line_offset + $line_num + 1;
$trimmed = trim($line);
// Skip comments
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*')) {
continue;
}
// Check for render() method definition
// Match: render() { or async render() {
// But not: on_render() or other_render() or renderSomething()
if (preg_match('/^(?:async\s+)?render\s*\([^)]*\)\s*\{/', $trimmed)) {
$this->add_violation(
$file_path,
$actual_line_number,
"Component class '{$class_name}' overrides render() method. This is not allowed in JQHTML components.",
trim($line),
'Use lifecycle methods instead: on_render() for initial render setup, on_create() for post-render initialization, or on_ready() for final setup. The render() method is reserved for the framework to render templates.',
'high'
);
}
// Also check for static render method (which would also be wrong)
if (preg_match('/^static\s+(?:async\s+)?render\s*\([^)]*\)\s*\{/', $trimmed)) {
$this->add_violation(
$file_path,
$actual_line_number,
"Component class '{$class_name}' defines a static render() method. This is not allowed in JQHTML components.",
trim($line),
'Use lifecycle methods instead: on_render() for initial render setup, on_create() for post-render initialization, or on_ready() for final setup.',
'high'
);
}
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JsFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-FALLBACK-01';
}
public function get_name(): string
{
return 'JavaScript Fallback/Legacy Code Check';
}
public function get_description(): string
{
return 'Enforces fail-loud principle - no fallback implementations allowed';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check JavaScript file for fallback/legacy code in comments and function calls (from line 1415)
* Enforces fail-loud principle - no fallback implementations allowed
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Use original content to check comments before sanitization
$original_content = file_get_contents($file_path);
$lines = explode("\n", $original_content);
// Also get sanitized content to check for function calls
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for fallback or legacy in comments (case insensitive, with word boundaries)
// But allow fallback* or legacy* as marked exceptions
if (preg_match('/\/\/.*\b(fallback|legacy)\b(?!\*)/i', $line) ||
preg_match('/\/\*.*\b(fallback|legacy)\b(?!\*)/i', $line) ||
preg_match('/\*.*\b(fallback|legacy)\b(?!\*)/i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"CRITICAL: Fallback/legacy code detected in comment. This violates the fail-loud principle - there must be ONE way to do things.",
trim($line),
"REMOVE the fallback/legacy code immediately. If primary code fails, it MUST throw an exception, NOT execute alternative code. Fallbacks create non-deterministic behavior and debugging nightmares. Only ONE implementation should exist. If you're an AI/LLM agent: DO NOT decide to keep fallback code - you MUST ask the developer for explicit approval. To mark legitimate uses (rare), add '*' after the word. Remember: Predictable failures are better than unpredictable 'working' code.",
'critical'
);
}
// Check for dual implementation patterns in single-line comments
// These patterns suggest old/new dual implementations which violate the no-fallback rule
if (preg_match('/\/\/.*\bold\W+.*\bnew\b/i', $line) || // "old" ... "new"
preg_match('/\/\/.*\bprevious\W+.*\bnew\b/i', $line) || // "previous" ... "new"
preg_match('/\/\/.*\bbackwards?\s+compatability/i', $line)) { // "backward(s) compatibility" (common misspelling)
$this->add_violation(
$file_path,
$line_number,
"🚨 CRITICAL DUAL IMPLEMENTATION VIOLATION: Comment suggests old/new or backwards compatibility patterns\n\nThis violates the fundamental architecture principle: There is NO 'old' and 'new' way - there is ONLY THE WAY.\nDual implementations cause:\n1. CONFUSION about which code path executes\n2. DUPLICATE CODE that diverges over time\n3. DEBUGGING NIGHTMARES when behavior differs\n4. MAINTENANCE BURDEN keeping both paths in sync",
trim($line),
"IMMEDIATE ACTION REQUIRED:\n1. IDENTIFY what 'old' vs 'new' refers to\n2. REMOVE the old implementation entirely\n3. UPDATE all references to use the single implementation\n4. DO NOT maintain backwards compatibility\n\nFor AI/LLM agents: This is a CRITICAL violation. You MUST:\n- Search for BOTH implementations referenced in the comment\n- Report findings to user before making changes\n- Remove the OLD implementation completely\n- Update all code to use the single 'new' (current) way\n- NEVER maintain dual implementations for compatibility\n\nRemember: There is only ONE way to do things. What's currently called 'new' IS the way.",
'critical'
);
}
// Check for function calls starting or ending with 'fallback'
// This catches: fallback(), fallback_loader(), document_loader_fallback(), etc.
// But NOT: document_fallback_loader() (fallback in middle)
// Use sanitized line to check if there's actual code, but check pattern on original line
if (isset($sanitized_lines[$line_num]) && trim($sanitized_lines[$line_num]) !== '') {
// There's non-comment code on this line
// Pattern matches functions that start with "fallback" OR end with "fallback"
if (preg_match('/\b(fallback\w*|\w+fallback)\s*\(/i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"CRITICAL: Fallback function call detected. This violates the fail-loud principle - there must be ONE way to do things.",
trim($line),
"REMOVE the fallback function immediately or RENAME it if it's legitimate required program flow (not an alternative implementation). If primary code fails, it MUST throw an exception, NOT execute alternative code. Fallbacks create non-deterministic behavior and debugging nightmares. Only ONE implementation should exist.",
'critical'
);
}
}
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class JsLegacyFunction_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-LEGACY-FUNC-01';
}
public function get_name(): string
{
return 'JavaScript Legacy Function Comment Check';
}
public function get_description(): string
{
return 'Prohibits functions with "legacy" in block comments - enforces no backwards compatibility principle';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check for block comments containing "legacy" directly before function definitions
* Enforces RSX principle of no backwards compatibility functions
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip node_modules directories
if (str_contains($file_path, '/node_modules/')) {
return;
}
// Skip vendor directories
if (str_contains($file_path, '/vendor/')) {
return;
}
$lines = explode("\n", $contents);
$in_block_comment = false;
$block_comment_content = '';
$block_comment_start_line = 0;
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_number = $i + 1;
$trimmed_line = trim($line);
// Track block comment state
if (str_contains($trimmed_line, '/*')) {
$in_block_comment = true;
$block_comment_start_line = $line_number;
$block_comment_content = $line;
// Handle single-line block comments
if (str_contains($trimmed_line, '*/')) {
$in_block_comment = false;
$this->check_block_comment_for_legacy($file_path, $block_comment_content, $block_comment_start_line, $lines, $i);
$block_comment_content = '';
}
continue;
}
if ($in_block_comment) {
$block_comment_content .= "\n" . $line;
if (str_contains($trimmed_line, '*/')) {
$in_block_comment = false;
$this->check_block_comment_for_legacy($file_path, $block_comment_content, $block_comment_start_line, $lines, $i);
$block_comment_content = '';
}
continue;
}
}
}
/**
* Check if a block comment contains "legacy" and is followed by a function
*/
private function check_block_comment_for_legacy(string $file_path, string $comment_content, int $comment_start_line, array $lines, int $comment_end_index): void
{
// Check if comment contains "legacy" (case insensitive)
if (!preg_match('/\blegacy\b/i', $comment_content)) {
return;
}
// Look for function definition in the next few lines after comment
for ($j = $comment_end_index + 1; $j < min($comment_end_index + 5, count($lines)); $j++) {
$next_line = trim($lines[$j]);
// Skip empty lines and single-line comments
if (empty($next_line) || str_starts_with($next_line, '//')) {
continue;
}
// Check if this line contains a JavaScript function definition
if (preg_match('/^(static\s+)?function\s+\w+\s*\(/i', $next_line) || // function name()
preg_match('/^\w+\s*:\s*function\s*\(/i', $next_line) || // name: function()
preg_match('/^(static\s+)?\w+\s*\(/i', $next_line) || // static name() or name() (ES6 class method)
preg_match('/^(const|let|var)\s+\w+\s*=\s*(async\s+)?function/i', $next_line) || // const name = function
preg_match('/^(const|let|var)\s+\w+\s*=\s*(async\s+)?\(/i', $next_line) || // const name = () =>
preg_match('/^\w+\s*=\s*(async\s+)?function/i', $next_line) || // name = function
preg_match('/^\w+\s*=\s*(async\s+)?\(/i', $next_line)) { // name = () =>
$this->add_violation(
$file_path,
$comment_start_line,
"🚨 CRITICAL BACKWARDS COMPATIBILITY VIOLATION: JavaScript function with 'legacy' comment detected
RSX Framework Principle: NO BACKWARDS COMPATIBILITY FUNCTIONS ALLOWED
The presence of 'legacy' in function comments indicates backwards compatibility code, which violates the core RSX principle of 'one way to do things'. Legacy functions create:
1. MULTIPLE CODE PATHS for the same functionality
2. MAINTENANCE BURDEN keeping old interfaces working
3. CONFUSION about which method to use
4. TECHNICAL DEBT that accumulates over time
5. DEBUGGING NIGHTMARES when behavior differs between paths
RSX enforces FAIL LOUD architecture - when things change, they should break clearly, not fall back to old implementations.",
trim($comment_content),
"IMMEDIATE ACTION REQUIRED:
1. REMOVE the legacy function entirely
2. UPDATE all callers to use the current implementation
3. DO NOT maintain backwards compatibility
4. If migration is needed, create a one-time migration script that:
- Identifies all usage of the old function
- Updates code to use new function
- Removes old function after migration is complete
For AI/LLM agents: This is a CRITICAL violation. You MUST:
- Search for ALL callers of this legacy function
- Report findings to user before making changes
- Remove the legacy function completely
- Update all code to use the current implementation
- NEVER maintain dual implementations for compatibility
Remember: There is only ONE way to do things in RSX. Legacy functions violate this fundamental principle.",
'critical'
);
return;
}
// If we hit a non-function code line, stop looking
break;
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\Core\Manifest\Manifest;
class JsRspadeInternalMethod_CodeQualityRule extends CodeQualityRule_Abstract
{
protected static $rspade_classes = null;
public function get_id(): string
{
return 'JS-INTERNAL-01';
}
public function get_name(): string
{
return 'JavaScript RSpade Internal Method Usage Check';
}
public function get_description(): string
{
return 'Prohibits calling internal RSpade methods (starting with _) from application code';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Get list of RSpade JavaScript classes from manifest
*/
protected function get_rspade_classes(): array
{
if (static::$rspade_classes === null) {
static::$rspade_classes = [];
// Get all files from manifest
$all_files = Manifest::get_all();
foreach ($all_files as $file_path => $metadata) {
// Check if it's a JavaScript file with a class
$extension = $metadata['extension'] ?? '';
if (in_array($extension, ['*.js']) && isset($metadata['class'])) {
// Handle Windows backslash issue - normalize to forward slashes
$normalized_path = str_replace('\\', '/', $file_path);
// Check if the file is in app/RSpade directory
if (str_contains($normalized_path, 'app/RSpade/')) {
static::$rspade_classes[] = $metadata['class'];
}
}
}
}
return static::$rspade_classes;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get RSpade classes
$rspade_classes = $this->get_rspade_classes();
if (empty($rspade_classes)) {
return; // No RSpade JS classes found
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
// Check for each RSpade class
foreach ($rspade_classes as $class_name) {
// Pattern to match ClassName._method
// Must be preceded by non-alphanumeric or beginning of line
$pattern = '/(?:^|[^a-zA-Z0-9_])(' . preg_quote($class_name, '/') . ')\._[a-zA-Z0-9_]+\s*\(/';
if (preg_match($pattern, $sanitized_line, $matches)) {
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Extract the method call for better error message
preg_match('/' . preg_quote($class_name, '/') . '\._[a-zA-Z0-9_]+/', $sanitized_line, $method_match);
$method_call = $method_match[0] ?? $class_name . '._method';
$this->add_violation(
$file_path,
$line_number,
"Calling internal RSpade method. Methods starting with _ are for framework internal use only.",
trim($original_line),
"Do not call methods starting with underscore on RSpade framework classes. " .
"The method '{$method_call}()' is internal to the framework and may change without notice in updates. " .
"Use only public API methods (those not starting with underscore).",
'high'
);
}
}
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* LifecycleMethodsStaticRule - Ensures RSX lifecycle methods are static
*
* This rule checks JavaScript ES6 classes for RSX framework lifecycle methods
* and ensures they are declared as static. These methods are called by the
* framework at specific initialization phases and must be static to work correctly.
*/
class LifecycleMethodsStatic_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* RSX lifecycle methods that must be static
* These are called by the framework during initialization phases
*/
private const LIFECYCLE_METHODS = [
'_on_framework_core_define',
'_on_framework_modules_define',
'_on_framework_core_init',
'on_app_modules_define',
'on_app_define',
'_on_framework_modules_init',
'on_app_modules_init',
'on_app_init',
'on_app_ready',
'on_jqhtml_ready',
];
public function get_id(): string
{
return 'JS-LIFECYCLE-01';
}
public function get_name(): string
{
return 'RSX Lifecycle Methods Must Be Static';
}
public function get_description(): string
{
return 'Ensures RSX framework lifecycle methods (on_app_ready, etc.) are declared as static in ES6 classes';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Whether this rule is called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* lifecycle methods must be static for the framework's auto-initialization to function correctly.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Check JavaScript file for lifecycle methods that aren't static
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a JavaScript file with a class
if (!isset($metadata['class'])) {
return;
}
// Get class name
$class_name = $metadata['class'];
// Only check classes that extend Jqhtml_Component
if (!\App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component')) {
return;
}
// Check regular (non-static) methods for lifecycle methods that should be static
if (isset($metadata['public_instance_methods']) && is_array($metadata['public_instance_methods'])) {
foreach ($metadata['public_instance_methods'] as $method_name => $method_info) {
if (in_array($method_name, self::LIFECYCLE_METHODS, true)) {
// This lifecycle method is not static - violation!
// Find the line number by searching for the method definition
$line_number = $this->find_method_line($contents, $method_name);
$code_line = $this->extract_code_line($contents, $line_number);
$this->add_violation(
$file_path,
$line_number,
"RSX lifecycle method '{$method_name}' must be declared as static",
$code_line,
$this->get_remediation($method_name, $class_name, $code_line),
'high'
);
}
}
}
}
/**
* Find the line number where a method is defined
*/
private function find_method_line(string $contents, string $method_name): int
{
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
// Look for method definition (with or without async)
if (preg_match('/\b(async\s+)?' . preg_quote($method_name, '/') . '\s*\(/', $line)) {
return $line_num + 1;
}
}
return 1; // Default to line 1 if not found
}
/**
* Extract code line for a given line number
*/
private function extract_code_line(string $contents, int $line_number): string
{
$lines = explode("\n", $contents);
if (isset($lines[$line_number - 1])) {
return trim($lines[$line_number - 1]);
}
return '';
}
/**
* Get remediation instructions for non-static lifecycle method
*/
private function get_remediation(string $method, ?string $class_name, string $current_line): string
{
$is_async = strpos($current_line, 'async') !== false;
$static_version = $is_async ? "static async {$method}()" : "static {$method}()";
$class_info = $class_name ? " in class {$class_name}" : "";
$phase_description = $this->get_phase_description($method);
return "FRAMEWORK CONVENTION: RSX lifecycle method '{$method}'{$class_info} must be static.
PROBLEM:
The method is currently defined as an instance method, but the RSX framework
calls these methods statically during application initialization.
SOLUTION:
Change the method declaration to: {$static_version}
CURRENT (INCORRECT):
{$current_line}
CORRECTED:
{$static_version} {
// Your initialization code here
}
WHY THIS MATTERS:
- The RSX framework calls lifecycle methods statically on classes
- Instance methods cannot be called without instantiating the class
- The framework does not instantiate classes during initialization
- Using instance methods will cause the initialization to fail silently
LIFECYCLE PHASE:
This method runs during: {$phase_description}
ALL RSX LIFECYCLE METHODS (must be static):
- _on_framework_core_define() - Framework core modules define phase
- _on_framework_modules_define() - Framework extension modules define
- _on_framework_core_init() - Framework core initialization
- on_app_modules_define() - Application modules define phase
- on_app_define() - Application-wide define phase
- _on_framework_modules_init() - Framework modules initialization
- on_app_modules_init() - Application modules initialization
- on_app_init() - Application initialization
- on_app_ready() - Application ready (DOM loaded, components initialized)
Methods prefixed with underscore (_) are internal framework methods.
Application code should typically only use the non-underscore methods.";
}
/**
* Get description of when this lifecycle phase runs
*/
private function get_phase_description(string $method): string
{
$descriptions = [
'_on_framework_core_define' => 'Framework core module definition (internal)',
'_on_framework_modules_define' => 'Framework extension module definition (internal)',
'_on_framework_core_init' => 'Framework core initialization (internal)',
'on_app_modules_define' => 'Application module definition phase',
'on_app_define' => 'Application-wide definition phase',
'_on_framework_modules_init' => 'Framework module initialization (internal)',
'on_app_modules_init' => 'Application module initialization',
'on_app_init' => 'Application initialization',
'on_app_ready' => 'Application ready - DOM loaded, components initialized',
];
return $descriptions[$method] ?? 'Unknown phase';
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class NativeFunction_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-NATIVE-01';
}
public function get_name(): string
{
return 'JavaScript Native Function Usage Check';
}
public function get_description(): string
{
return 'Enforces RSpade functions instead of native JavaScript functions';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Native function patterns and their replacements
$function_patterns = [
// Array.isArray()
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])Array\.isArray\s*\(/i',
'native' => 'Array.isArray()',
'replacement' => 'is_array()',
'message' => 'Use is_array() instead of Array.isArray().'
],
// parseFloat()
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])parseFloat\s*\(/i',
'native' => 'parseFloat()',
'replacement' => 'float()',
'message' => 'Use float() instead of parseFloat().'
],
// parseInt()
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])parseInt\s*\(/i',
'native' => 'parseInt()',
'replacement' => 'int()',
'message' => 'Use int() instead of parseInt().'
],
// String() constructor
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])String\s*\(/i',
'native' => 'String()',
'replacement' => 'str()',
'message' => 'Use str() instead of String().'
],
// encodeURIComponent()
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])encodeURIComponent\s*\(/i',
'native' => 'encodeURIComponent()',
'replacement' => 'urlencode()',
'message' => 'Use urlencode() instead of encodeURIComponent().'
],
// decodeURIComponent()
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])decodeURIComponent\s*\(/i',
'native' => 'decodeURIComponent()',
'replacement' => 'urldecode()',
'message' => 'Use urldecode() instead of decodeURIComponent().'
],
// JSON.stringify()
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])JSON\.stringify\s*\(/i',
'native' => 'JSON.stringify()',
'replacement' => 'json_encode()',
'message' => 'Use json_encode() instead of JSON.stringify().'
],
// JSON.parse()
[
'pattern' => '/(?:^|[^a-zA-Z0-9_])JSON\.parse\s*\(/i',
'native' => 'JSON.parse()',
'replacement' => 'json_decode()',
'message' => 'Use json_decode() instead of JSON.parse().'
],
];
foreach ($function_patterns as $check) {
if (preg_match($check['pattern'], $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
$check['message'],
trim($original_line),
"Replace '{$check['native']}' with '{$check['replacement']}'. " .
"RSpade provides PHP-like functions that should be used instead of native JavaScript functions. " .
"This provides consistency across PHP and JavaScript code and ensures predictable behavior.",
'medium'
);
break; // Only report first match per line
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class NoControllerSuffix_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-CONTROLLER-01';
}
public function get_name(): string
{
return 'No Controller Suffix in JavaScript';
}
public function get_description(): string
{
return 'JavaScript classes cannot use Controller suffix - reserved for PHP controllers';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check that JavaScript classes don't use Controller suffix
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Check if metadata has a class name (from Manifest)
if (!isset($metadata['class'])) {
return;
}
$class_name = $metadata['class'];
// Check if class name ends with Controller or _controller (case sensitive)
if (str_ends_with($class_name, 'Controller') || str_ends_with($class_name, '_controller')) {
// Find the line where the class is declared
$lines = explode("\n", $contents);
$line_number = 0;
$code_snippet = '';
foreach ($lines as $i => $line) {
// Look for ES6 class declaration
if (preg_match('/\bclass\s+' . preg_quote($class_name) . '\b/', $line)) {
$line_number = $i + 1;
$code_snippet = $this->get_code_snippet($lines, $i);
break;
}
}
$resolution = "JavaScript class '{$class_name}' uses the reserved 'Controller' suffix.\n\n";
$resolution .= "WHY THIS IS PROHIBITED:\n";
$resolution .= "The 'Controller' suffix is reserved exclusively for PHP controller classes because:\n";
$resolution .= "1. It maintains clear separation between frontend and backend code\n";
$resolution .= "2. It prevents confusion when making Ajax_Endpoint_Controller calls from JavaScript\n";
$resolution .= "3. It ensures consistent naming conventions across the codebase\n";
$resolution .= "4. Controllers handle HTTP requests and must be server-side PHP classes\n\n";
$resolution .= "HOW TO FIX:\n";
$resolution .= "Rename the JavaScript class to use a different suffix that describes its purpose:\n";
// Generate suggestions based on common patterns
$base_name = preg_replace('/(Controller|_controller)$/', '', $class_name);
$suggestions = [
$base_name . 'Manager' => 'For managing state or coordinating components',
$base_name . 'Handler' => 'For handling events or user interactions',
$base_name . 'Component' => 'For UI components',
$base_name . 'Service' => 'For service/API interaction logic',
$base_name . 'View' => 'For view-related logic',
$base_name . 'Widget' => 'For reusable UI widgets',
$base_name . 'Helper' => 'For utility/helper functions',
];
$resolution .= "\nSUGGESTED ALTERNATIVES:\n";
foreach ($suggestions as $suggestion => $description) {
$resolution .= "- {$suggestion}: {$description}\n";
}
$resolution .= "\nREMEMBER:\n";
$resolution .= "- JavaScript handles frontend logic and UI interactions\n";
$resolution .= "- PHP Controllers handle HTTP requests and backend logic\n";
$resolution .= "- When JavaScript needs to call a controller, use Ajax.internal() or fetch() to the controller's API endpoint\n\n";
$resolution .= "If this naming is absolutely required (extremely rare), add '@JS-CONTROLLER-01-EXCEPTION' comment.";
$this->add_violation(
$file_path,
$line_number,
"JavaScript class '{$class_name}' uses reserved 'Controller' suffix",
$code_snippet,
$resolution,
'high'
);
}
}
}

View File

@@ -0,0 +1,387 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* NoPageLoadAnimationRule - Detect animations that occur on page load
*
* This rule ensures all page elements appear immediately on initial page load.
* Animations are only allowed in specific scenarios:
* - After loading data via AJAX (discouraged but allowed)
* - In response to user interaction (click, checkbox, etc)
* - For position:absolute overlays like modals
*/
class NoPageLoadAnimation_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Animation methods that we want to detect
* Note: show() and hide() without duration are allowed as they're instant
*/
private const ANIMATION_METHODS = [
'animate',
'fadeIn',
'fadeOut',
'fadeTo',
'fadeToggle',
'slideIn',
'slideOut',
'slideDown',
'slideUp',
'slideToggle'
];
/**
* Init methods where animations are not allowed (unless in anonymous function)
*/
private const INIT_METHODS = [
'_on_framework_core_define',
'_on_framework_modules_define',
'_on_framework_core_init',
'on_app_modules_define',
'on_app_define',
'_on_framework_modules_init',
'on_app_modules_init',
'on_app_init',
'on_app_ready'
];
/**
* Event binding methods that indicate user interaction
*/
private const EVENT_METHODS = [
'on',
'click',
'change',
'submit',
'keydown',
'keyup',
'keypress',
'mouseenter',
'mouseleave',
'hover',
'focus',
'blur',
'addEventListener'
];
/**
* AJAX methods that indicate data loading
*/
private const AJAX_METHODS = [
'ajax',
'get',
'post',
'getJSON',
'load',
'done',
'success',
'complete',
'then',
'fetch'
];
/**
* Get the unique identifier for this rule
*/
public function get_id(): string
{
return 'JS-ANIMATION-01';
}
/**
* Get the default severity level
*/
public function get_default_severity(): string
{
return 'critical';
}
/**
* Get the file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.js', '*.jqhtml'];
}
/**
* Get the display name for this rule
*/
public function get_name(): string
{
return 'No Page Load Animation';
}
/**
* Get the description of what this rule checks
*/
public function get_description(): string
{
return 'Detects animations on initial page load - all elements must appear immediately';
}
/**
* Check the file contents for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip minified files
if (str_contains($file_path, '.min.js')) {
return;
}
$lines = explode("\n", $contents);
$in_init_method = false;
$init_method_name = '';
$brace_depth = 0;
$init_method_depth = 0;
foreach ($lines as $line_num => $line) {
// Check if we're entering an init method
if ($this->_is_entering_init_method($line)) {
$in_init_method = true;
$init_method_name = $this->_extract_method_name($line);
$init_method_depth = $brace_depth;
}
// Track brace depth
$brace_depth += substr_count($line, '{');
$brace_depth -= substr_count($line, '}');
// Check if we're leaving the init method
if ($in_init_method && $brace_depth <= $init_method_depth) {
$in_init_method = false;
$init_method_name = '';
}
// If we're in an init method, check for animations
if ($in_init_method) {
// Check if this line is inside an anonymous function (including arrow functions)
if ($this->_is_in_anonymous_function($lines, $line_num) ||
$this->_is_in_event_handler($lines, $line_num) ||
$this->_is_in_ajax_callback($lines, $line_num)) {
continue; // Allowed context
}
// Check for animation calls
foreach (self::ANIMATION_METHODS as $method) {
// Pattern for jQuery style: .animate( or .fadeIn(
if (preg_match('/\.\s*' . preg_quote($method, '/') . '\s*\(/i', $line)) {
// Check for specific exceptions
if ($this->_is_allowed_animation($line, $lines, $line_num)) {
continue;
}
$this->add_violation(
$line_num + 1,
strpos($line, $method),
"Animation on page load detected: .{$method}()",
trim($line),
"Remove animation from {$init_method_name}(). Elements must appear immediately on page load.\n" .
"If you need to show/hide elements at page load, use .show() or .hide() instead of fade/slide effects.\n" .
"Animations are only allowed:\n" .
"- In response to user interaction (click, change, etc)\n" .
"- After AJAX data loading (discouraged)\n" .
"- For position:absolute overlays (modals)"
);
}
}
// Also check for direct opacity manipulation during init
if (preg_match('/\.css\s*\(\s*[\'"]opacity[\'"]/', $line) &&
(str_contains($line, 'setTimeout') || str_contains($line, 'setInterval'))) {
$this->add_violation(
$line_num + 1,
strpos($line, 'opacity'),
"Delayed opacity change on page load detected",
trim($line),
"Remove opacity animation from {$init_method_name}(). Use CSS for initial styling."
);
}
}
}
}
/**
* Check if we're entering an init method
*/
private function _is_entering_init_method(string $line): bool
{
foreach (self::INIT_METHODS as $method) {
// Match: static method_name() or function method_name()
if (preg_match('/(?:static\s+|function\s+)?' . preg_quote($method, '/') . '\s*\(/i', $line)) {
return true;
}
// Match: method_name: function()
if (preg_match('/' . preg_quote($method, '/') . '\s*:\s*function\s*\(/i', $line)) {
return true;
}
}
return false;
}
/**
* Extract the method name from a line
*/
private function _extract_method_name(string $line): string
{
foreach (self::INIT_METHODS as $method) {
if (str_contains($line, $method)) {
return $method;
}
}
return 'initialization';
}
/**
* Check if current context is inside an anonymous function (including arrow functions)
*/
private function _is_in_anonymous_function(array $lines, int $current_line): bool
{
// Count function depth by looking backwards
$function_depth = 0;
$paren_depth = 0;
$brace_depth = 0;
// Look backwards from current line to find function declarations
for ($i = $current_line; $i >= 0; $i--) {
$line = $lines[$i];
// Count braces to track scope
$brace_depth += substr_count($line, '}');
$brace_depth -= substr_count($line, '{');
// If we've exited all scopes, stop looking
if ($brace_depth > 0) {
break;
}
// Check for anonymous function patterns
// Regular function: function() { or function(args) {
if (preg_match('/function\s*\([^)]*\)\s*{/', $line)) {
return true;
}
// Arrow function: () => { or (args) => {
if (preg_match('/\([^)]*\)\s*=>\s*{/', $line)) {
return true;
}
// Single arg arrow function: arg => {
if (preg_match('/\w+\s*=>\s*{/', $line)) {
return true;
}
// Common callback patterns: setTimeout, setInterval, forEach, map, filter, etc.
if (preg_match('/(setTimeout|setInterval|forEach|map|filter|reduce|some|every|find)\s*\(\s*(function|\([^)]*\)\s*=>|\w+\s*=>)/', $line)) {
return true;
}
// jQuery each pattern: .each(function() or .each((i, el) =>
if (preg_match('/\.each\s*\(\s*(function|\([^)]*\)\s*=>)/', $line)) {
return true;
}
}
return false;
}
/**
* Check if current context is inside an event handler
*/
private function _is_in_event_handler(array $lines, int $current_line): bool
{
// Look backwards for event binding within 10 lines
$start = max(0, $current_line - 10);
for ($i = $current_line; $i >= $start; $i--) {
$line = $lines[$i];
foreach (self::EVENT_METHODS as $event) {
// Check for .on('click', or .click( patterns
if (preg_match('/\.\s*' . preg_quote($event, '/') . '\s*\(/i', $line)) {
return true;
}
// Check for addEventListener
if (str_contains($line, 'addEventListener')) {
return true;
}
}
}
return false;
}
/**
* Check if current context is inside an AJAX callback
*/
private function _is_in_ajax_callback(array $lines, int $current_line): bool
{
// Look backwards for AJAX methods within 10 lines
$start = max(0, $current_line - 10);
for ($i = $current_line; $i >= $start; $i--) {
$line = $lines[$i];
foreach (self::AJAX_METHODS as $ajax) {
if (preg_match('/\.\s*' . preg_quote($ajax, '/') . '\s*\(/i', $line) ||
preg_match('/\$\s*\.\s*' . preg_quote($ajax, '/') . '\s*\(/i', $line)) {
return true;
}
}
// Check for promise patterns
if (str_contains($line, '.then(') || str_contains($line, 'async ') || str_contains($line, 'await ')) {
return true;
}
}
return false;
}
/**
* Check if this is an allowed animation exception
*/
private function _is_allowed_animation(string $line, array $lines, int $line_num): bool
{
// Check for modal or overlay keywords
$allowed_selectors = [
'modal',
'overlay',
'popup',
'dialog',
'tooltip',
'dropdown-menu',
'position-absolute',
'position-fixed'
];
foreach ($allowed_selectors as $selector) {
if (str_contains(strtolower($line), $selector)) {
return true;
}
}
// Check if the element being animated has position:absolute in a nearby style
// This is harder to detect statically, so we'll be conservative
// Check for comments indicating AJAX loading
if ($line_num > 0) {
$prev_line = $lines[$line_num - 1];
if (str_contains(strtolower($prev_line), 'ajax') ||
str_contains(strtolower($prev_line), 'load') ||
str_contains(strtolower($prev_line), 'fetch')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* JavaScript 'this' Usage Rule
*
* PHILOSOPHY: Remove ambiguity about what 'this' refers to in all contexts.
*
* RULES:
* 1. Anonymous functions: Can use 'const $var = $(this)' as first line (jQuery pattern)
* 2. Instance methods: Must use 'const that = this' as first line (constructors exempt)
* 3. Static methods: Use Class_Name OR 'const CurrentClass = this' for polymorphism
* 4. Arrow functions: Ignored (they inherit 'this' context)
* 5. Constructors: Direct 'this' usage allowed for property assignment
*
* PATTERNS:
* - jQuery: const $element = $(this) // Variable must start with $
* - Instance: const that = this // Standard instance aliasing
* - Static (exact): Use Class_Name // When you need exact class
* - Static (polymorphic): const CurrentClass = this // When inherited classes need different behavior
*
* This rule does NOT try to detect all jQuery callbacks - it offers the jQuery
* pattern as an option when 'this' violations are found in anonymous functions.
*/
class ThisUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-THIS-01';
}
public function get_name(): string
{
return "JavaScript 'this' Usage Check";
}
public function get_description(): string
{
return "Enforces clear 'this' patterns: jQuery callbacks use '\$element = \$(this)', instance methods use 'that = this'";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript file for improper 'this' usage
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check JavaScript files that contain ES6 classes
if (!isset($metadata['class'])) {
return; // Not a class file
}
// Get violations from AST parser
$violations = $this->parse_with_acorn($file_path);
if (empty($violations)) {
return;
}
// Process violations
foreach ($violations as $violation) {
$this->add_violation(
$file_path,
$violation['line'],
$violation['message'],
$violation['codeSnippet'],
$violation['remediation'],
$this->get_default_severity()
);
}
}
/**
* Use Node.js with acorn to parse JavaScript and find violations
* Uses external parser script stored in resources directory
*/
private function parse_with_acorn(string $file_path): array
{
// Setup cache directory
$cache_dir = storage_path('rsx-tmp/cache/code-quality/js-this');
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
// Cache based on file modification time
$cache_key = md5($file_path) . '-' . filemtime($file_path);
$cache_file = $cache_dir . '/' . $cache_key . '.json';
// Check cache first
if (file_exists($cache_file)) {
$cached = json_decode(file_get_contents($cache_file), true);
if ($cached !== null) {
return $cached;
}
}
// Clean old cache files for this source file
$pattern = $cache_dir . '/' . md5($file_path) . '-*.json';
foreach (glob($pattern) as $old_cache) {
if ($old_cache !== $cache_file) {
unlink($old_cache);
}
}
// Path to the parser script
$parser_script = __DIR__ . '/resource/this-usage-parser.js';
if (!file_exists($parser_script)) {
// Parser script missing - fatal error
throw new \RuntimeException("JS-THIS parser script missing: {$parser_script}");
}
// Run Node.js parser with the external script
$output = shell_exec("cd /tmp && node " . escapeshellarg($parser_script) . " " . escapeshellarg($file_path) . " 2>&1");
if (!$output) {
return [];
}
$result = json_decode($output, true);
if (!$result) {
return [];
}
// Check for errors from the parser
if (isset($result['error'])) {
// Parser encountered an error but it's not fatal for the rule
return [];
}
$violations = $result['violations'] ?? [];
// Cache the result
file_put_contents($cache_file, json_encode($violations));
return $violations;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class TypeofCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-TYPEOF-01';
}
public function get_name(): string
{
return 'JavaScript typeof Usage Check';
}
public function get_description(): string
{
return 'Enforces RSpade type checking functions instead of typeof';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Check for typeof patterns and their replacements
$typeof_patterns = [
// typeof something == 'string' or === 'string' (single or double quotes)
[
'pattern' => '/typeof\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[!=]=+\s*[\'"]string[\'"]/i',
'replacement' => 'is_string($1)',
'message' => 'Use is_string() instead of typeof for string type checking.'
],
// typeof something == 'object' or === 'object'
[
'pattern' => '/typeof\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[!=]=+\s*[\'"]object[\'"]/i',
'replacement' => 'is_object($1)',
'message' => 'Use is_object() instead of typeof for object type checking.'
],
// typeof value != 'undefined' or !== 'undefined'
[
'pattern' => '/typeof\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*!==?\s*[\'"]undefined[\'"]/i',
'replacement' => 'isset($1)',
'message' => 'Use isset() instead of typeof for undefined checking.'
],
// typeof value == 'undefined' or === 'undefined'
[
'pattern' => '/typeof\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*===?\s*[\'"]undefined[\'"]/i',
'replacement' => '!isset($1)',
'message' => 'Use !isset() instead of typeof for undefined checking.'
],
// typeof something == 'number' or === 'number'
[
'pattern' => '/typeof\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[!=]=+\s*[\'"]number[\'"]/i',
'replacement' => 'is_numeric($1)',
'message' => 'Use is_numeric() instead of typeof for number type checking.'
],
// typeof something == 'boolean' or === 'boolean'
[
'pattern' => '/typeof\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[!=]=+\s*[\'"]boolean[\'"]/i',
'replacement' => 'is_bool($1)',
'message' => 'Use is_bool() instead of typeof for boolean type checking.'
],
// typeof something == 'function' or === 'function'
[
'pattern' => '/typeof\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[!=]=+\s*[\'"]function[\'"]/i',
'replacement' => 'is_callable($1)',
'message' => 'Use is_callable() instead of typeof for function type checking.'
],
];
foreach ($typeof_patterns as $check) {
if (preg_match($check['pattern'], $sanitized_line, $matches)) {
$this->add_violation(
$file_path,
$line_number,
$check['message'],
trim($original_line),
"Replace with '{$check['replacement']}'. " .
"RSpade provides PHP-like type checking functions that should be used instead of native JavaScript typeof. " .
"This provides consistency across PHP and JavaScript code.",
'medium'
);
break; // Only report first match per line
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class VarUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-VAR-01';
}
public function get_name(): string
{
return 'JavaScript var Keyword Check';
}
public function get_description(): string
{
return "Enforces use of 'let' and 'const' instead of 'var'";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Check JavaScript file for 'var' keyword usage (from line 1269)
* Enforces use of 'let' and 'const' instead of 'var'
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Check for 'var' keyword at the beginning of a line or after ( or ;
// This catches var declarations in for loops and inline declarations
if (preg_match('/(?:^\s*|[\(;]\s*)var\s+/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Use of 'var' keyword is not allowed. Use 'let' or 'const' instead for better scoping and immutability.",
trim($line),
"Replace 'var' with 'let' for mutable variables or 'const' for immutable values.",
'medium'
);
}
}
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class WindowAssignment_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-WINDOW-01';
}
public function get_name(): string
{
return 'Window Assignment Check';
}
public function get_description(): string
{
return "Prohibits window.ClassName = ClassName assignments in ALL code (framework and application). RSX uses simple concatenation, not modules - all classes are automatically global when files are concatenated together, just like JavaScript worked in 1998 before module systems existed.";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript files for window.ClassName = ClassName pattern
* RSX uses concatenation, not modules - all classes become global automatically
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Check for window.ClassName = ClassName pattern
// This matches window.Anything = Anything
if (preg_match('/window\.(\w+)\s*=\s*\1\s*;?/', $line, $matches)) {
$class_name = $matches[1];
$this->add_violation(
$file_path,
$line_number,
"Manual window assignment detected for class '{$class_name}'. RSX framework automatically makes classes global.",
trim($line),
"Remove the line 'window.{$class_name} = {$class_name}' - the class is already global via the RSX manifest system.",
'high'
);
}
// Also check for generic window assignments that might be problematic
// This catches window.SomeName = OtherName patterns that may also be incorrect
if (preg_match('/^\s*window\.([A-Z]\w+)\s*=\s*([A-Z]\w+)\s*;?/', $line, $matches)) {
// Only flag if it's not the same-name pattern (already handled above)
if ($matches[1] !== $matches[2]) {
$this->add_violation(
$file_path,
$line_number,
"Manual window assignment detected. Consider if this is necessary - RSX classes are automatically global.",
trim($line),
"Verify if this manual window assignment is needed. RSX framework classes don't need window assignment.",
'medium'
);
}
}
}
}
}

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env node
/**
* JavaScript 'this' usage parser for code quality checks
*
* PURPOSE: Parse JavaScript files to find 'this' usage violations
* according to RSpade coding standards.
*
* USAGE: node this-usage-parser.js <filepath>
* OUTPUT: JSON with violations array
*
* RULES:
* - Anonymous functions can use: const $var = $(this) as first line
* - Instance methods must use: const that = this as first line
* - Static methods should never use 'this', use ClassName instead
* - Arrow functions are ignored (they inherit 'this')
*
* @FILENAME-CONVENTION-EXCEPTION - Node.js utility script
*/
const fs = require('fs');
const acorn = require('acorn');
const walk = require('acorn-walk');
// Known jQuery callback methods - used for better remediation messages
const JQUERY_CALLBACKS = new Set([
'click', 'dblclick', 'mouseenter', 'mouseleave', 'mousedown', 'mouseup',
'mousemove', 'mouseover', 'mouseout', 'change', 'submit', 'focus', 'blur',
'keydown', 'keyup', 'keypress', 'resize', 'scroll', 'select', 'load',
'on', 'off', 'one', 'each', 'map', 'filter',
'fadeIn', 'fadeOut', 'slideDown', 'slideUp', 'animate',
'done', 'fail', 'always', 'then', 'ready', 'hover'
]);
function analyzeFile(filePath) {
try {
const code = fs.readFileSync(filePath, 'utf8');
const lines = code.split('\n');
let ast;
try {
ast = acorn.parse(code, {
ecmaVersion: 2020,
sourceType: 'module',
locations: true
});
} catch (e) {
// Parse error - return empty violations
return { violations: [], error: `Parse error: ${e.message}` };
}
const violations = [];
const classInfo = new Map(); // Track class info
// First pass: identify all classes and their types
walk.simple(ast, {
ClassDeclaration(node) {
const hasStaticInit = node.body.body.some(member =>
member.static && member.key?.name === 'init'
);
classInfo.set(node.id.name, {
isStatic: hasStaticInit
});
}
});
// Helper to check if first line of function has valid pattern
function checkFirstLinePattern(funcNode) {
if (!funcNode.body || !funcNode.body.body || funcNode.body.body.length === 0) {
return null;
}
let checkIndex = 0;
const firstStmt = funcNode.body.body[0];
// Check if first statement is e.preventDefault() or similar
if (firstStmt.type === 'ExpressionStatement' &&
firstStmt.expression?.type === 'CallExpression' &&
firstStmt.expression?.callee?.type === 'MemberExpression' &&
firstStmt.expression?.callee?.property?.name === 'preventDefault') {
// First line is preventDefault, check second line for pattern
checkIndex = 1;
if (funcNode.body.body.length <= 1) {
return null; // No second statement
}
}
const targetStmt = funcNode.body.body[checkIndex];
if (targetStmt.type !== 'VariableDeclaration') {
return null;
}
const firstDecl = targetStmt.declarations[0];
if (!firstDecl || !firstDecl.init) {
return null;
}
const varKind = targetStmt.kind; // 'const', 'let', or 'var'
// Check for 'that = this' pattern
if (firstDecl.id.name === 'that' &&
firstDecl.init.type === 'ThisExpression') {
if (varKind !== 'const') {
return 'that-pattern-wrong-kind';
}
return 'that-pattern';
}
// Check for 'CurrentClass = this' pattern (for static polymorphism)
if (firstDecl.id.name === 'CurrentClass' &&
firstDecl.init.type === 'ThisExpression') {
if (varKind !== 'const') {
return 'currentclass-pattern-wrong-kind';
}
return 'currentclass-pattern';
}
// Check for '$var = $(this)' pattern
if (firstDecl.id.name.startsWith('$') &&
firstDecl.init.type === 'CallExpression' &&
firstDecl.init.callee.name === '$' &&
firstDecl.init.arguments.length === 1 &&
firstDecl.init.arguments[0].type === 'ThisExpression') {
if (varKind !== 'const') {
return 'jquery-pattern-wrong-kind';
}
return 'jquery-pattern';
}
return null;
}
// Helper to detect if we're in a jQuery callback (best effort)
function isLikelyJQueryCallback(ancestors) {
for (let i = ancestors.length - 1; i >= 0; i--) {
const node = ancestors[i];
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
const methodName = node.callee.property.name;
if (JQUERY_CALLBACKS.has(methodName)) {
return true;
}
}
}
return false;
}
// Walk the AST looking for 'this' usage
walk.ancestor(ast, {
ThisExpression(node, ancestors) {
// Skip arrow functions - they inherit 'this'
for (const ancestor of ancestors) {
if (ancestor.type === 'ArrowFunctionExpression') {
return; // Skip - arrow functions inherit context
}
}
// Find containing function and class
let containingFunc = null;
let containingClass = null;
let isAnonymousFunc = false;
let isStaticMethod = false;
let isConstructor = false;
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (!containingFunc && (
ancestor.type === 'FunctionExpression' ||
ancestor.type === 'FunctionDeclaration'
)) {
containingFunc = ancestor;
isAnonymousFunc = ancestor.type === 'FunctionExpression';
}
if (!containingClass && (
ancestor.type === 'ClassDeclaration' ||
ancestor.type === 'ClassExpression'
)) {
containingClass = ancestor;
}
if (ancestor.type === 'MethodDefinition') {
isStaticMethod = ancestor.static;
isConstructor = ancestor.kind === 'constructor';
}
}
if (!containingFunc) {
return; // Not in a function
}
// Skip constructors - 'this' is allowed for property assignment
if (isConstructor) {
return;
}
// Check if this is part of the allowed first-line pattern with const
const parent = ancestors[ancestors.length - 2];
const firstStmt = containingFunc.body?.body?.[0];
let checkIndex = 0;
// Check if first statement is preventDefault
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.type === 'CallExpression' &&
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
if (hasPreventDefault) {
checkIndex = 1;
}
const targetStmt = containingFunc.body?.body?.[checkIndex];
const isTargetConst = targetStmt?.type === 'VariableDeclaration' && targetStmt?.kind === 'const';
// Check if this 'this' is inside $(this) on the first or second line
// For jQuery pattern: const $var = $(this)
if (parent && parent.type === 'CallExpression' && parent.callee?.name === '$') {
// This is $(this) - check if it's in the right position with const
if (isTargetConst &&
targetStmt?.declarations?.[0]?.init === parent &&
targetStmt?.declarations?.[0]?.id?.name?.startsWith('$')) {
return; // This is const $var = $(this) in correct position
}
}
// Check if this 'this' is the 'const that = this' in correct position
if (parent && parent.type === 'VariableDeclarator' &&
parent.id?.name === 'that' &&
isTargetConst &&
targetStmt?.declarations?.[0]?.id?.name === 'that') {
return; // This is 'const that = this' in correct position
}
// Check if this 'this' is the 'const CurrentClass = this' in correct position
if (parent && parent.type === 'VariableDeclarator' &&
parent.id?.name === 'CurrentClass' &&
isTargetConst &&
targetStmt?.declarations?.[0]?.id?.name === 'CurrentClass') {
return; // This is 'const CurrentClass = this' in correct position
}
// Check what pattern is used
const pattern = checkFirstLinePattern(containingFunc);
// Determine the violation and remediation
let message = '';
let remediation = '';
const lineNum = node.loc.start.line;
const codeSnippet = lines[lineNum - 1].trim();
const className = containingClass?.id?.name || 'unknown';
const isJQueryContext = isLikelyJQueryCallback(ancestors);
// Anonymous functions take precedence - even if inside a static method
if (isAnonymousFunc) {
if (!pattern) {
// Check if there's a preventDefault on the first line
const firstStmt = containingFunc.body?.body?.[0];
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.type === 'CallExpression' &&
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
if (isJQueryContext) {
message = `'this' in jQuery callback should be aliased for clarity.`;
if (hasPreventDefault) {
remediation = `Add 'const $element = $(this);' as the second line (after preventDefault), then use $element instead of 'this'.`;
} else {
remediation = `Add 'const $element = $(this);' as the first line of this function, then use $element instead of 'this'.`;
}
} else {
message = `Ambiguous 'this' usage in anonymous function.`;
if (hasPreventDefault) {
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as the second line (after preventDefault).\n` +
`If this is an instance context: Add 'const that = this;' as the second line.\n` +
`Then use the aliased variable instead of 'this'.`;
} else {
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as first line.\n` +
`If this is an instance context: Add 'const that = this;' as first line.\n` +
`Then use the aliased variable instead of 'this'.`;
}
}
} else if (pattern === 'that-pattern' || pattern === 'jquery-pattern') {
message = `'this' used after aliasing. Use the aliased variable instead.`;
// Find the variable declaration (might be first or second statement)
let varDeclIndex = 0;
const firstStmt = containingFunc.body?.body?.[0];
if (firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault') {
varDeclIndex = 1;
}
const varName = containingFunc.body?.body?.[varDeclIndex]?.declarations?.[0]?.id?.name;
remediation = pattern === 'jquery-pattern'
? `You already have 'const ${varName} = $(this)'. Use that variable instead of 'this'.`
: `You already have 'const that = this'. Use 'that' instead of 'this'.`;
} else if (pattern === 'that-pattern-wrong-kind') {
message = `Instance alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const that = this;' - the instance reference should never be reassigned.`;
} else if (pattern === 'jquery-pattern-wrong-kind') {
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
}
} else if (isStaticMethod) {
if (!pattern) {
message = `Static method in '${className}' should not use naked 'this'.`;
remediation = `Static methods have two options:\n` +
`1. If you need the exact class (no polymorphism): Replace 'this' with '${className}'\n` +
`2. If you need polymorphism (inherited classes): Add 'const CurrentClass = this;' as first line\n` +
` Then use CurrentClass.name or CurrentClass.property for polymorphic access.\n` +
`Consider: Does this.name need to work for child classes? If yes, use CurrentClass pattern.`;
} else if (pattern === 'currentclass-pattern') {
message = `'this' used after aliasing to CurrentClass. Use 'CurrentClass' instead.`;
remediation = `You already have 'const CurrentClass = this;'. Use 'CurrentClass' for all access, not naked 'this'.`;
} else if (pattern === 'currentclass-pattern-wrong-kind') {
message = `CurrentClass pattern must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const CurrentClass = this;' - the CurrentClass reference should never be reassigned.`;
} else if (pattern === 'jquery-pattern') {
// jQuery pattern in static method's anonymous function is OK
return;
} else if (pattern === 'jquery-pattern-wrong-kind') {
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
}
if (isAnonymousFunc && !pattern) {
remediation += `\nException: If this is a jQuery callback, add 'const $element = $(this);' as the first line.`;
}
} else {
// Instance method
if (!pattern) {
message = `Instance method in '${className}' must alias 'this' for clarity.`;
remediation = `Add 'const that = this;' as the first line of this method, then use 'that' instead of 'this'.\n` +
`This applies even to ORM models and similar classes where direct property access is common.\n` +
`Note: Constructors are exempt - 'this' is allowed directly in constructors for property assignment.\n` +
`Example: Instead of 'return this.name;' use 'const that = this; return that.name;'`;
} else if (pattern === 'that-pattern') {
message = `'this' used after aliasing to 'that'. Use 'that' instead.`;
remediation = `You already have 'const that = this'. Use 'that' consistently throughout the method.\n` +
`All property access should use 'that.property' not 'this.property'.`;
} else if (pattern === 'that-pattern-wrong-kind') {
message = `Instance alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const that = this;' - the instance reference should never be reassigned.`;
}
}
if (message) {
violations.push({
line: lineNum,
message: message,
codeSnippet: codeSnippet,
remediation: remediation
});
}
}
});
return { violations: violations };
} catch (error) {
return { violations: [], error: error.message };
}
}
// Main execution
const filePath = process.argv[2];
if (!filePath) {
console.log(JSON.stringify({ violations: [], error: 'No file path provided' }));
process.exit(1);
}
if (!fs.existsSync(filePath)) {
console.log(JSON.stringify({ violations: [], error: `File not found: ${filePath}` }));
process.exit(1);
}
const result = analyzeFile(filePath);
console.log(JSON.stringify(result));

View File

@@ -0,0 +1,171 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Jqhtml;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* JQHTML Component Naming Rule
*
* Enforces that all jqhtml component names start with an uppercase letter.
* This is a hard requirement of the jqhtml library.
*/
class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique identifier for this rule
*/
public function get_id(): string
{
return 'JQHTML-NAMING-01';
}
/**
* Get the human-readable name of this rule
*/
public function get_name(): string
{
return 'JQHTML Component Names Must Start Uppercase';
}
/**
* Get the description of what this rule checks
*/
public function get_description(): string
{
return 'Ensures all jqhtml component names start with an uppercase letter (library requirement)';
}
/**
* Get file patterns this rule should check
*/
public function get_file_patterns(): array
{
return ['*.jqhtml', '*.js'];
}
/**
* This rule should run at manifest-time for immediate feedback
* since incorrect naming would break the jqhtml library
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Critical library requirement
}
/**
* Check the file for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Check .jqhtml files for Define: tags
if (str_ends_with($file_path, '.jqhtml')) {
$this->check_jqhtml_file($file_path, $contents);
}
// Check .js files for classes extending Jqhtml_Component
if (str_ends_with($file_path, '.js')) {
$this->check_javascript_file($file_path, $contents, $metadata);
}
}
/**
* Check jqhtml template files
*/
private function check_jqhtml_file(string $file_path, string $contents): void
{
$lines = explode("\n", $contents);
$line_number = 0;
foreach ($lines as $line) {
$line_number++;
// Look for <Define:ComponentName> tags
if (preg_match('/<Define:([a-zA-Z_][a-zA-Z0-9_]*)>/', $line, $matches)) {
$component_name = $matches[1];
// Check if first character is not uppercase
if (!ctype_upper($component_name[0])) {
$this->add_violation(
$file_path,
$line_number,
"JQHTML component name '{$component_name}' must start with an uppercase letter",
trim($line),
"Change '{$component_name}' to '" . ucfirst($component_name) . "'. This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
'critical'
);
}
}
}
}
/**
* Check JavaScript files for Jqhtml_Component subclasses
*/
private function check_javascript_file(string $file_path, string $contents, array $metadata = []): void
{
$lines = explode("\n", $contents);
$line_number = 0;
// Get JavaScript class from manifest metadata
$js_classes = [];
if (isset($metadata['class']) && isset($metadata['extension']) && $metadata['extension'] === 'js') {
$js_classes = [$metadata['class']];
}
// If no classes in metadata, nothing to check for class definitions
if (!empty($js_classes)) {
// Find line numbers for each class
$class_definitions = [];
foreach ($js_classes as $class_name) {
// Find where this class is defined in the source
foreach ($lines as $idx => $line) {
if (preg_match('/class\s+' . preg_quote($class_name, '/') . '\s+/', $line)) {
$class_definitions[$class_name] = $idx + 1;
break;
}
}
}
// Check each class to see if it's a JQHTML component
foreach ($class_definitions as $class_name => $line_num) {
// Use Manifest to check if this is a JQHTML component (handles indirect inheritance)
if (Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component')) {
// Check if first character is not uppercase
if (!ctype_upper($class_name[0])) {
$this->add_violation(
$file_path,
$line_num,
"JQHTML component class '{$class_name}' must start with an uppercase letter",
trim($lines[$line_num - 1]),
"Change '{$class_name}' to '" . ucfirst($class_name) . "'. This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
'critical'
);
}
}
}
}
// Still check for component registration patterns
foreach ($lines as $line) {
$line_number++;
// Also check for component registration patterns
if (preg_match('/jqhtml\.component\([\'"]([a-zA-Z_][a-zA-Z0-9_]*)[\'"]/', $line, $matches)) {
$component_name = $matches[1];
if (!ctype_upper($component_name[0])) {
$this->add_violation(
$file_path,
$line_number,
"JQHTML component registration '{$component_name}' must use uppercase name",
trim($line),
"Change '{$component_name}' to '" . ucfirst($component_name) . "'. This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
'critical'
);
}
}
}
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Jqhtml;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Rule: JQHTML-EVENT-01
*
* Detects incorrect usage of event.preventDefault() in JQHTML component event handlers.
* JQHTML @event attributes don't pass DOM event objects - they call methods directly
* and preventDefault is handled automatically by the framework.
*/
class JqhtmlEventPreventDefault_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JQHTML-EVENT-01';
}
public function get_name(): string
{
return 'JQHTML Event preventDefault Usage';
}
public function get_description(): string
{
return 'Detects incorrect usage of event.preventDefault() in JQHTML component event handlers. ' .
"JQHTML @event attributes don't pass DOM event objects - they call methods directly " .
'and preventDefault is handled automatically by the framework.';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
public function is_called_during_manifest_scan(): bool
{
return false;
}
/**
* Check JavaScript files for JQHTML event handler violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check JavaScript files
if (!str_ends_with($file_path, '.js')) {
return;
}
// Get JavaScript class from manifest metadata
$js_classes = [];
if (isset($metadata['class']) && isset($metadata['extension']) && $metadata['extension'] === 'js') {
$js_classes = [$metadata['class']];
}
// If no classes in metadata, nothing to check
if (empty($js_classes)) {
return;
}
// Find the first class that extends Jqhtml_Component
$class_name = null;
foreach ($js_classes as $js_class) {
if (Manifest::js_is_subclass_of($js_class, 'Jqhtml_Component')) {
$class_name = $js_class;
break;
}
}
if (!$class_name) {
return; // No JQHTML components found
}
// Look for corresponding .jqhtml template file
$dir = dirname($file_path);
$possible_templates = [
$dir . '/' . $class_name . '.jqhtml',
// Convert CamelCase to snake_case for template name
$dir . '/' . $this->to_snake_case($class_name) . '.jqhtml',
// Check without _component suffix if present
$dir . '/' . str_replace('_component', '', $this->to_snake_case($class_name)) . '.jqhtml',
];
$template_content = null;
$template_path = null;
foreach ($possible_templates as $template_file) {
if (file_exists($template_file)) {
$template_content = file_get_contents($template_file);
$template_path = $template_file;
break;
}
}
if (!$template_content) {
// No template found, can't verify usage
return;
}
// Find all methods that use event.preventDefault()
$lines = explode("\n", $contents);
$in_method = false;
$method_name = null;
$method_start_line = 0;
$method_param = null;
$brace_count = 0;
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for method definition with single parameter
if (preg_match('/^\s*(\w+)\s*\(\s*(\w+)\s*\)\s*{/', $line, $method_match)) {
$in_method = true;
$method_name = $method_match[1];
$method_param = $method_match[2];
$method_start_line = $line_number;
$brace_count = 1;
} elseif ($in_method) {
// Count braces to track method boundaries
$brace_count += substr_count($line, '{');
$brace_count -= substr_count($line, '}');
if ($brace_count <= 0) {
$in_method = false;
$method_name = null;
$method_param = null;
continue;
}
// Check if this line calls preventDefault on the parameter
if ($method_param && preg_match('/\b' . preg_quote($method_param, '/') . '\s*\.\s*preventDefault\s*\(/', $line)) {
// Found preventDefault usage, now check if this method is used in template
if ($this->method_used_in_template($template_content, $method_name)) {
$this->add_violation(
$file_path,
$line_number,
"JQHTML event handlers should not use event.preventDefault() - Method '{$method_name}()' is called from JQHTML template but tries to use event.preventDefault(). " .
"JQHTML automatically handles preventDefault and doesn't pass event objects.",
trim($line),
"Remove '{$method_param}' parameter and '{$method_param}.preventDefault()' call",
'high'
);
}
}
}
}
}
/**
* Check if a method is referenced in JQHTML template event attributes
*/
private function method_used_in_template(string $template_content, string $method_name): bool
{
// Look for @event=this.method_name or @event=method_name patterns
// Common events: @click, @change, @submit, @keyup, @keydown, @focus, @blur
$pattern = '/@\w+\s*=\s*(?:this\.)?' . preg_quote($method_name, '/') . '\b/';
return preg_match($pattern, $template_content) > 0;
}
/**
* Convert CamelCase/PascalCase to snake_case
*/
private function to_snake_case(string $str): string
{
$str = preg_replace('/([a-z])([A-Z])/', '$1_$2', $str);
return strtolower($str);
}
}

View File

@@ -0,0 +1,330 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Jqhtml;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* JqhtmlInlineScriptRule - Enforces no inline scripts or styles in .jqhtml template files
*
* This rule checks .jqhtml component template files for inline <script> or <style> tags
* and provides remediation instructions for creating separate JS and SCSS files that
* follow Jqhtml component patterns.
*/
class JqhtmlInlineScript_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JQHTML-INLINE-01';
}
public function get_name(): string
{
return 'Jqhtml Inline Script/Style Check';
}
public function get_description(): string
{
return 'Enforces no inline JavaScript or CSS in .jqhtml templates - must use separate component class and SCSS files';
}
public function get_file_patterns(): array
{
return ['*.jqhtml'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* This rule should run during manifest scan to provide immediate feedback
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* inline scripts/styles in Jqhtml files violate critical framework architecture patterns.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update to extract inline script/style violations
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
$lines = explode("\n", $contents);
$violations = [];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for <script> tags (excluding external script src)
if (preg_match('/<script\b[^>]*>(?!.*src=)/i', $line)) {
$violations[] = [
'type' => 'inline_script',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need to find first violation
}
// Check for <style> tags
if (preg_match('/<style\b[^>]*>/i', $line)) {
$violations[] = [
'type' => 'inline_style',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need to find first violation
}
}
if (!empty($violations)) {
return ['jqhtml_inline_violations' => $violations];
}
return null;
}
/**
* Check jqhtml file for inline script/style violations stored in metadata
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Check for violations in code quality metadata
if (isset($metadata['code_quality_metadata']['JQHTML-INLINE-01']['jqhtml_inline_violations'])) {
$violations = $metadata['code_quality_metadata']['JQHTML-INLINE-01']['jqhtml_inline_violations'];
// Throw on first violation
foreach ($violations as $violation) {
$component_id = $this->extract_component_id($contents);
if ($violation['type'] === 'inline_script') {
$error_message = "Code Quality Violation (JQHTML-INLINE-01) - Inline Script in Jqhtml Template\n\n";
$error_message .= "CRITICAL: Inline <script> tags are not allowed in .jqhtml templates\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$violation['line']}\n";
$error_message .= "Code: {$violation['code']}\n\n";
$error_message .= $this->get_script_remediation($file_path, $component_id);
} else {
$error_message = "Code Quality Violation (JQHTML-INLINE-01) - Inline Style in Jqhtml Template\n\n";
$error_message .= "CRITICAL: Inline <style> tags are not allowed in .jqhtml templates\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$violation['line']}\n";
$error_message .= "Code: {$violation['code']}\n\n";
$error_message .= $this->get_style_remediation($file_path, $component_id);
}
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($file_path),
$violation['line']
);
}
}
}
/**
* Extract component ID from jqhtml file content
* Looks for <Define:ComponentName> pattern
*/
private function extract_component_id(string $contents): ?string
{
if (preg_match('/<Define:([A-Z][A-Za-z0-9_]*)>/', $contents, $matches)) {
return $matches[1];
}
return null;
}
/**
* Get detailed remediation instructions for scripts
*/
private function get_script_remediation(string $file_path, ?string $component_id): string
{
// Determine the JS filename and class name
$path_parts = pathinfo($file_path);
$base_name = $path_parts['filename'];
// Use component ID if available, otherwise use filename
$class_name = $component_id ?: str_replace('_', '', ucwords($base_name, '_'));
$js_filename = $path_parts['filename'] . '.js';
$js_path = dirname($file_path) . '/' . $js_filename;
return "FRAMEWORK CONVENTION: JavaScript for jqhtml components must be in separate ES6 class files.
REQUIRED STEPS:
1. Create a JavaScript file: {$js_path}
2. Name the ES6 class exactly: {$class_name}
3. Extend Jqhtml_Component base class
4. Implement lifecycle methods: on_create(), on_load(), on_ready()
EXAMPLE IMPLEMENTATION for {$js_filename}:
/**
* Component class for {$class_name}
*/
class {$class_name} extends Jqhtml_Component {
/**
* Called when component instance is created
* Use for initial setup and event binding
*/
async on_create() {
// Initialize component state
this.state = {
count: 0,
loading: false
};
// Bind events to elements with \$id attribute in template
// Example: <button \$onclick=\"handle_click\">Click Me</button>
}
/**
* Called to load data (before rendering)
* Use for async data fetching - NO DOM manipulation here
*/
async on_load() {
// Fetch any required data
// this.data contains data passed to component
// Example:
// const response = await fetch('/api/data');
// this.remote_data = await response.json();
}
/**
* Called after component is fully rendered and ready
* Use for final DOM setup
*/
async on_ready() {
// Component is fully loaded and rendered
// The \$. property gives you the jQuery element
this.\$.addClass('loaded');
// Access template elements via \$id
// Example: this.\$.find('[data-id=\"title\"]')
}
// Event handlers referenced in template
handle_click(event) {
this.state.count++;
this.render(); // Re-render component with new state
}
}
KEY CONVENTIONS:
- Class name MUST match the <Define:{$class_name}> in the .jqhtml file
- MUST extend Jqhtml_Component base class
- Use lifecycle methods: on_create(), on_load(), on_ready()
- Access component element via this.\$
- Bind events using \$onclick, \$onchange, etc. in template
- Use this.render() to re-render with updated state
- NO inline scripts in the .jqhtml template
WHY THIS MATTERS:
- Separation of concerns: Template structure separate from behavior
- Component reusability: Clean component architecture
- Framework integration: Automatic lifecycle management
- Testability: JavaScript logic can be tested independently
- Performance: Components only initialize when needed";
}
/**
* Get detailed remediation instructions for styles
*/
private function get_style_remediation(string $file_path, ?string $component_id): string
{
// Determine the SCSS filename and class name
$path_parts = pathinfo($file_path);
$scss_filename = $path_parts['filename'] . '.scss';
$scss_path = dirname($file_path) . '/' . $scss_filename;
// Use component ID if available, otherwise use filename
$class_name = $component_id ?: str_replace('_', '', ucwords($path_parts['filename'], '_'));
return "FRAMEWORK CONVENTION: Styles for jqhtml components must be in separate SCSS files.
REQUIRED STEPS:
1. Create a SCSS file: {$scss_path}
2. Wrap ALL styles in .{$class_name} selector
3. Every instance of the component will have class=\"{$class_name}\" automatically
EXAMPLE IMPLEMENTATION for {$scss_filename}:
/**
* Styles for {$class_name} component
*/
.{$class_name} {
// All component styles MUST be nested within this class
// This ensures styles are scoped to this component only
// Component container styles
display: block;
padding: 1rem;
border: 1px solid \$border-color;
border-radius: 4px;
// Child element styles
.header {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.content {
padding: 0.5rem 0;
.item {
margin: 0.25rem 0;
}
}
// State-based styles
&.loaded {
opacity: 1;
transition: opacity 0.3s;
}
&.loading {
opacity: 0.5;
pointer-events: none;
}
// Responsive styles
@media (max-width: 768px) {
padding: 0.5rem;
}
}
KEY CONVENTIONS:
- ALL styles MUST be nested within .{$class_name} { }
- Component automatically gets class=\"{$class_name}\" on root element
- Use SCSS nesting for child elements
- Use & for state modifiers (&.loaded, &.active)
- Import shared variables if needed (\$border-color, etc.)
- NO inline styles in the .jqhtml template
- NO global styles that could affect other components
WHY THIS MATTERS:
- Style encapsulation: Component styles don't leak to other components
- Predictable cascade: Clear style hierarchy within component
- Reusability: Component can be used multiple times without conflicts
- Maintainability: Styles organized with their components
- Framework convention: Consistent pattern across all components";
}
}

View File

@@ -0,0 +1,663 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Manifest;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* FilenameClassMatch_CodeQualityRule - Enforces filename matches class name
*
* Ensures that files containing classes have filenames that match the class name.
* - app/RSpade: case-sensitive exact match required
* - rsx: case-insensitive match allowed (snake_case encouraged)
*/
class FilenameClassMatch_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MANIFEST-FILENAME-01';
}
public function get_name(): string
{
return 'Filename Class Match';
}
public function get_description(): string
{
return 'Ensures filenames match the class names they contain';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js'];
}
public function is_called_during_manifest_scan(): bool
{
return false;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
static $already_checked = false;
// Only check once per manifest build
if ($already_checked) {
return;
}
$already_checked = true;
// Check if filename conventions are globally disabled
if (config('rsx.development.ignore_filename_convention', false)) {
return;
}
// Get all manifest files
$files = \App\RSpade\Core\Manifest\Manifest::get_all();
if (empty($files)) {
return;
}
foreach ($files as $file => $metadata) {
// Only check files in ./rsx or ./app/RSpade
$is_rsx = str_starts_with($file, 'rsx/');
$is_rspade = str_starts_with($file, 'app/RSpade/');
if (!$is_rsx && !$is_rspade) {
continue;
}
$extension = $metadata['extension'] ?? '';
$filename = basename($file);
// Check PHP/JS files with classes
if (isset($metadata['class'])) {
$this->check_class_filename($file, $metadata['class'], $extension, $filename, $is_rsx, $is_rspade);
}
// Check blade.php files with @rsx_id
if ($extension === 'blade.php' && isset($metadata['id'])) {
$this->check_blade_filename($file, $metadata['id'], $filename, $is_rsx, $is_rspade);
}
// Check jqhtml files with Define:
if ($extension === 'jqhtml' && isset($metadata['id'])) {
$this->check_jqhtml_filename($file, $metadata['id'], $filename, $is_rsx, $is_rspade);
}
}
}
private function check_class_filename(string $file, string $class_name, string $extension, string $filename, bool $is_rsx, bool $is_rspade): void
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// Check if this is a JavaScript class extending Jqhtml_Component
$is_jqhtml_component = false;
if ($extension === 'js') {
try {
$is_jqhtml_component = \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component');
} catch (\Exception $e) {
// Class not in manifest or not a JS class, treat as regular class
$is_jqhtml_component = false;
}
}
// For rsx/ Jqhtml components, allow flexible naming like jqhtml files
if ($is_rsx && $is_jqhtml_component) {
// Check exact match (case-insensitive)
if (strtolower($filename_without_ext) === strtolower($class_name)) {
return;
}
// Check snake_case variations
$snake_case = $this->pascal_to_snake_case($class_name);
if (strtolower($filename_without_ext) === strtolower($snake_case)) {
return;
}
// Check short name variations
$dir_path = dirname($file);
$short_name = $this->extract_short_name($class_name, $dir_path);
if ($short_name !== null) {
$short_snake = $this->pascal_to_snake_case($short_name);
if (strtolower($filename_without_ext) === strtolower($short_name) ||
strtolower($filename_without_ext) === strtolower($short_snake)) {
return;
}
}
} else {
// Regular class filename matching
$matches_full = $is_rspade
? $filename_without_ext === $class_name
: strtolower($filename_without_ext) === strtolower($class_name);
if ($matches_full) {
return; // Perfect match
}
// Check if short filename is valid (directory structure matches prefix)
$dir_path = dirname($file);
$short_name = $this->extract_short_name($class_name, $dir_path);
if ($short_name !== null) {
$matches_short = $is_rspade
? $filename_without_ext === $short_name
: strtolower($filename_without_ext) === strtolower($short_name);
if ($matches_short) {
return; // Valid short name
}
}
}
// Violation: filename doesn't match
// Determine suggested filename
$suggested_filename = $this->get_suggested_filename_from_remediation(
$file, $class_name, $extension, $is_rspade, $is_jqhtml_component
);
// Check if we should auto-rename or throw violation
if ($this->should_auto_rename_or_throw_violation($file, $suggested_filename, $is_rsx)) {
return; // File was renamed, manifest will restart
}
// Add violation
if ($is_rspade) {
$this->add_violation(
$file,
1,
"Filename '$filename' must match class name '$class_name' (case-sensitive)",
"class $class_name",
$this->get_class_remediation($file, $class_name, $filename, $extension, $is_rspade, $is_jqhtml_component),
'high'
);
} else {
$context = $is_jqhtml_component ? ' (Jqhtml component)' : '';
$this->add_violation(
$file,
1,
"Filename '$filename' must match class name '$class_name' (case-insensitive in rsx/){$context}",
"class $class_name",
$this->get_class_remediation($file, $class_name, $filename, $extension, $is_rspade, $is_jqhtml_component),
'medium'
);
}
}
private function check_blade_filename(string $file, string $rsx_id, string $filename, bool $is_rsx, bool $is_rspade): void
{
$filename_without_blade = str_replace('.blade.php', '', $filename);
// Check if filename matches (either full name or short name with matching directory structure)
$matches_full = $is_rspade
? $filename_without_blade === $rsx_id
: strtolower($filename_without_blade) === strtolower($rsx_id);
if ($matches_full) {
return; // Perfect match
}
// Check if short filename is valid (directory structure matches prefix)
$dir_path = dirname($file);
$short_name = $this->extract_short_name($rsx_id, $dir_path);
if ($short_name !== null) {
$matches_short = $is_rspade
? $filename_without_blade === $short_name
: strtolower($filename_without_blade) === strtolower($short_name);
if ($matches_short) {
return; // Valid short name
}
}
// Violation: filename doesn't match
// Determine suggested filename
$suggested_filename = $this->get_suggested_blade_filename_from_remediation($file, $rsx_id, $is_rspade);
// Check if we should auto-rename or throw violation
if ($this->should_auto_rename_or_throw_violation($file, $suggested_filename, $is_rsx)) {
return; // File was renamed, manifest will restart
}
// Add violation
if ($is_rspade) {
$this->add_violation(
$file,
1,
"Blade filename '$filename' must match @rsx_id '$rsx_id' (case-sensitive)",
"@rsx_id('$rsx_id')",
$this->get_blade_remediation($file, $rsx_id, $filename, $is_rspade),
'high'
);
} else {
$this->add_violation(
$file,
1,
"Blade filename '$filename' must match @rsx_id '$rsx_id' (case-insensitive in rsx/)",
"@rsx_id('$rsx_id')",
$this->get_blade_remediation($file, $rsx_id, $filename, $is_rspade),
'medium'
);
}
}
private function check_jqhtml_filename(string $file, string $component_name, string $filename, bool $is_rsx, bool $is_rspade): void
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// For rsx/, allow three formats for PascalCase component names:
// 1. Exact match (TestComponent1)
// 2. Snake_case with underscores (Test_Component_1)
// 3. Lowercase with underscores (test_component_1)
if ($is_rsx) {
// Check exact match (case-insensitive)
if (strtolower($filename_without_ext) === strtolower($component_name)) {
return;
}
// Check snake_case variations
$snake_case = $this->pascal_to_snake_case($component_name);
if (strtolower($filename_without_ext) === strtolower($snake_case)) {
return;
}
// Check short name variations
$dir_path = dirname($file);
$short_name = $this->extract_short_name($component_name, $dir_path);
if ($short_name !== null) {
$short_snake = $this->pascal_to_snake_case($short_name);
if (strtolower($filename_without_ext) === strtolower($short_name) ||
strtolower($filename_without_ext) === strtolower($short_snake)) {
return;
}
}
} else {
// app/RSpade: strict case-sensitive match
if ($filename_without_ext === $component_name) {
return;
}
// Check short name
$dir_path = dirname($file);
$short_name = $this->extract_short_name($component_name, $dir_path);
if ($short_name !== null && $filename_without_ext === $short_name) {
return;
}
}
// Violation: filename doesn't match
// Determine suggested filename
$suggested_filename = $this->get_suggested_jqhtml_filename_from_remediation($file, $component_name, $is_rspade);
// Check if we should auto-rename or throw violation
if ($this->should_auto_rename_or_throw_violation($file, $suggested_filename, $is_rsx)) {
return; // File was renamed, manifest will restart
}
// Add violation
if ($is_rspade) {
$this->add_violation(
$file,
1,
"Jqhtml filename '$filename' must match component name '$component_name' (case-sensitive)",
"<Define:$component_name>",
$this->get_jqhtml_remediation($file, $component_name, $filename, $is_rspade),
'high'
);
} else {
$this->add_violation(
$file,
1,
"Jqhtml filename '$filename' must match component name '$component_name' (case-insensitive in rsx/)",
"<Define:$component_name>",
$this->get_jqhtml_remediation($file, $component_name, $filename, $is_rspade),
'medium'
);
}
}
/**
* Convert PascalCase to snake_case
* Inserts underscores before uppercase letters and before first digit in a run of digits
* Example: TestComponent1 -> Test_Component_1
*/
private function pascal_to_snake_case(string $name): string
{
// Insert underscore before uppercase letters (except first character)
$result = preg_replace('/(?<!^)([A-Z])/', '_$1', $name);
// Insert underscore before first digit in a run of digits
$result = preg_replace('/(?<!^)(?<![0-9])([0-9])/', '_$1', $result);
// Replace multiple consecutive underscores with single underscore
$result = preg_replace('/_+/', '_', $result);
return $result;
}
/**
* Extract short name from class/id if directory structure matches prefix
* Returns null if directory structure doesn't match or if short name rules aren't met
*
* Rules:
* - Short names only allowed in ./rsx directory (NOT in /app/RSpade)
* - Original name must have 3+ segments for short name to be allowed (2-segment names must use full name)
* - Short name must have 2+ segments (exception: if original was 1 segment, short can be 1 segment)
*/
private function extract_short_name(string $full_name, string $dir_path): ?string
{
// Short names only allowed in ./rsx directory, not in framework code (/app/RSpade)
if (str_contains($dir_path, '/app/RSpade')) {
return null;
}
// Split the full name by underscores
$name_parts = explode('_', $full_name);
$original_segment_count = count($name_parts);
// If original name has exactly 2 segments, short name is NOT allowed
if ($original_segment_count === 2) {
return null;
}
// If only 1 segment, no prefix to match
if ($original_segment_count === 1) {
return null;
}
// Split directory path into parts and re-index
$dir_parts = array_values(array_filter(explode('/', $dir_path)));
// Find the maximum number of consecutive matches between end of dir_parts and start of name_parts
$matched_parts = 0;
$max_possible = min(count($dir_parts), count($name_parts) - 1);
// Try to match last N dir parts with first N name parts
for ($num_to_check = $max_possible; $num_to_check > 0; $num_to_check--) {
$all_match = true;
for ($i = 0; $i < $num_to_check; $i++) {
$dir_idx = count($dir_parts) - $num_to_check + $i;
if (strtolower($dir_parts[$dir_idx]) !== strtolower($name_parts[$i])) {
$all_match = false;
break;
}
}
if ($all_match) {
$matched_parts = $num_to_check;
break;
}
}
if ($matched_parts === 0) {
return null; // No match
}
// Calculate the short name
$short_parts = array_slice($name_parts, $matched_parts);
$short_segment_count = count($short_parts);
// Validate short name segment count
// Short name must have 2+ segments (unless original was 1 segment, which we already excluded above)
if ($short_segment_count < 2) {
return null; // Short name would be too short
}
return implode('_', $short_parts);
}
private function get_class_remediation(string $file, string $class_name, string $filename, string $extension, bool $is_rspade, bool $is_jqhtml_component = false): string
{
$dir_path = dirname($file);
$short_name = $this->extract_short_name($class_name, $dir_path);
$message = $is_rspade
? "Files in app/RSpade/ must have filenames that match the class name (case-sensitive).\n\n"
: "Files in rsx/ must have filenames that match the class name (case-insensitive).\n\n";
$message .= "Class name: $class_name\n";
$message .= "Current filename: $filename\n\n";
$message .= "Fix options:\n";
// For Jqhtml components in rsx/, use same flexible naming as jqhtml files
if (!$is_rspade && $is_jqhtml_component) {
$snake_case = $this->pascal_to_snake_case($class_name);
$snake_lower = strtolower($snake_case);
$options = [];
// Always suggest lowercase snake_case first (convention)
$options[] = [
'label' => 'RECOMMENDED (RSpade convention)',
'filename' => $snake_lower . '.' . $extension,
];
// If PascalCase differs from snake_case, offer it as alternative
if (strtolower($class_name) !== $snake_lower) {
$options[] = [
'label' => 'Alternative',
'filename' => strtolower($class_name) . '.' . $extension,
];
}
// Add options to message
$option_num = 1;
foreach ($options as $option) {
$message .= "{$option_num}. {$option['label']}: Rename to '{$option['filename']}'\n";
$message .= " mv '$filename' '{$option['filename']}'\n\n";
$option_num++;
}
} else {
// Regular class naming
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . strtolower($short_name) . '.' . $extension)) {
$short_filename = $is_rspade ? $short_name . '.' . $extension : strtolower($short_name) . '.' . $extension;
$message .= "1. RECOMMENDED (short name): Rename to '$short_filename'\n";
$message .= " mv '$filename' '$short_filename'\n\n";
$full_filename = $is_rspade ? $class_name . '.' . $extension : strtolower($class_name) . '.' . $extension;
$message .= "2. Full name: Rename to '$full_filename'\n";
$message .= " mv '$filename' '$full_filename'\n\n";
} else {
$full_filename = $is_rspade ? $class_name . '.' . $extension : strtolower($class_name) . '.' . $extension;
$message .= "1. Rename to '$full_filename'\n";
$message .= " mv '$filename' '$full_filename'\n\n";
}
}
return $message;
}
private function get_blade_remediation(string $file, string $rsx_id, string $filename, bool $is_rspade): string
{
$dir_path = dirname($file);
$short_name = $this->extract_short_name($rsx_id, $dir_path);
$message = $is_rspade
? "Blade files in app/RSpade/ must have filenames that match the @rsx_id (case-sensitive).\n\n"
: "Blade files in rsx/ must have filenames that match the @rsx_id (case-insensitive).\n\n";
$message .= "@rsx_id: $rsx_id\n";
$message .= "Current filename: $filename\n\n";
$message .= "Fix options:\n";
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . strtolower($short_name) . '.blade.php')) {
$short_filename = $is_rspade ? $short_name . '.blade.php' : strtolower($short_name) . '.blade.php';
$message .= "1. RECOMMENDED (short name): Rename to '$short_filename'\n";
$message .= " mv '$filename' '$short_filename'\n\n";
$full_filename = $is_rspade ? $rsx_id . '.blade.php' : strtolower($rsx_id) . '.blade.php';
$message .= "2. Full name: Rename to '$full_filename'\n";
$message .= " mv '$filename' '$full_filename'\n\n";
} else {
$full_filename = $is_rspade ? $rsx_id . '.blade.php' : strtolower($rsx_id) . '.blade.php';
$message .= "1. Rename to '$full_filename'\n";
$message .= " mv '$filename' '$full_filename'\n\n";
}
return $message;
}
private function get_jqhtml_remediation(string $file, string $component_name, string $filename, bool $is_rspade): string
{
$dir_path = dirname($file);
$short_name = $this->extract_short_name($component_name, $dir_path);
$message = $is_rspade
? "Jqhtml files in app/RSpade/ must have filenames that match the component name (case-sensitive).\n\n"
: "Jqhtml files in rsx/ must have filenames that match the component name (case-insensitive).\n\n";
$message .= "Component name: $component_name\n";
$message .= "Current filename: $filename\n\n";
$message .= "Fix options:\n";
if ($is_rspade) {
// app/RSpade: case-sensitive exact match only
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . $short_name . '.jqhtml')) {
$message .= "1. RECOMMENDED (short name): Rename to '{$short_name}.jqhtml'\n";
$message .= " mv '$filename' '{$short_name}.jqhtml'\n\n";
$message .= "2. Full name: Rename to '{$component_name}.jqhtml'\n";
$message .= " mv '$filename' '{$component_name}.jqhtml'\n\n";
} else {
$message .= "1. Rename to '{$component_name}.jqhtml'\n";
$message .= " mv '$filename' '{$component_name}.jqhtml'\n\n";
}
} else {
// rsx/: Allow PascalCase or snake_case (lowercase with underscores is convention)
$snake_case = $this->pascal_to_snake_case($component_name);
$snake_lower = strtolower($snake_case);
// Determine which options to show
$options = [];
// Always suggest lowercase snake_case first (convention)
$options[] = [
'label' => 'RECOMMENDED (RSpade convention)',
'filename' => $snake_lower . '.jqhtml',
];
// If PascalCase differs from snake_case, offer it as alternative
if (strtolower($component_name) !== $snake_lower) {
$options[] = [
'label' => 'Alternative',
'filename' => strtolower($component_name) . '.jqhtml',
];
}
// Add options to message
$option_num = 1;
foreach ($options as $option) {
$message .= "{$option_num}. {$option['label']}: Rename to '{$option['filename']}'\n";
$message .= " mv '$filename' '{$option['filename']}'\n\n";
$option_num++;
}
}
return $message;
}
/**
* Check if file should be auto-renamed or if violation should be thrown
* Returns true if file was renamed (signals manifest restart needed)
* Returns false if violation should be added
*/
private function should_auto_rename_or_throw_violation(string $file, string $suggested_filename, bool $is_rsx): bool
{
// Check if file has @FILENAME-CONVENTION-EXCEPTION marker
$file_contents = file_get_contents(base_path($file));
if (str_contains($file_contents, '@FILENAME-CONVENTION-EXCEPTION')) {
return true; // Skip this file entirely (no violation, no rename)
}
// Check if auto-rename is enabled and file is in rsx/
if (!config('rsx.development.auto_rename_files', false) || !$is_rsx) {
return false; // Throw normal violation
}
// Check if target filename already exists
$target_path = dirname($file) . '/' . $suggested_filename;
if (file_exists(base_path($target_path))) {
return false; // Conflict - throw normal violation
}
// Perform rename
$old_path = base_path($file);
$new_path = base_path($target_path);
rename($old_path, $new_path);
console_debug('MANIFEST', "Auto-renamed: {$file}{$target_path}");
// Signal manifest to restart
\App\RSpade\Core\Manifest\Manifest::flag_needs_restart();
return true; // File was renamed, no violation needed
}
/**
* Extract suggested filename for class files from remediation logic
*/
private function get_suggested_filename_from_remediation(string $file, string $class_name, string $extension, bool $is_rspade, bool $is_jqhtml_component): string
{
$dir_path = dirname($file);
$short_name = $this->extract_short_name($class_name, $dir_path);
if ($is_rspade) {
// app/RSpade: case-sensitive
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . $short_name . '.' . $extension)) {
return $short_name . '.' . $extension;
}
return $class_name . '.' . $extension;
} else {
// rsx/: For Jqhtml components, use snake_case
if ($is_jqhtml_component) {
$snake_case = $this->pascal_to_snake_case($class_name);
return strtolower($snake_case) . '.' . $extension;
}
// Regular classes
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . strtolower($short_name) . '.' . $extension)) {
return strtolower($short_name) . '.' . $extension;
}
return strtolower($class_name) . '.' . $extension;
}
}
/**
* Extract suggested filename for blade files from remediation logic
*/
private function get_suggested_blade_filename_from_remediation(string $file, string $rsx_id, bool $is_rspade): string
{
$dir_path = dirname($file);
$short_name = $this->extract_short_name($rsx_id, $dir_path);
if ($is_rspade) {
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . $short_name . '.blade.php')) {
return $short_name . '.blade.php';
}
return $rsx_id . '.blade.php';
} else {
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . strtolower($short_name) . '.blade.php')) {
return strtolower($short_name) . '.blade.php';
}
return strtolower($rsx_id) . '.blade.php';
}
}
/**
* Extract suggested filename for jqhtml files from remediation logic
*/
private function get_suggested_jqhtml_filename_from_remediation(string $file, string $component_name, bool $is_rspade): string
{
$dir_path = dirname($file);
$short_name = $this->extract_short_name($component_name, $dir_path);
if ($is_rspade) {
if ($short_name !== null && !file_exists(dirname(base_path($file)) . '/' . $short_name . '.jqhtml')) {
return $short_name . '.jqhtml';
}
return $component_name . '.jqhtml';
} else {
// rsx/: use snake_case (lowercase with underscores)
$snake_case = $this->pascal_to_snake_case($component_name);
return strtolower($snake_case) . '.jqhtml';
}
}
}

View File

@@ -0,0 +1,336 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Manifest;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* InstanceMethodsRule - Enforces static-only classes unless marked Instantiatable
*
* By default, classes should use static methods only (namespace pattern).
* Classes can have instance methods only if:
* 1. The class itself has #[Instantiatable] attribute (PHP) or @Instantiatable decorator (JS)
* 2. Any ancestor class has the Instantiatable attribute/decorator
*
* This enforces the framework's static-first architecture while allowing
* instance methods for specific use cases (UI components, ORM models, etc.)
*/
class InstanceMethods_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'MANIFEST-INST-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Instance Methods Validation';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Enforces static-only classes unless class or ancestor has Instantiatable attribute/decorator';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php', '*.js'];
}
/**
* This rule runs during manifest scan
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
/**
* Check the manifest for instance method violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
static $already_checked = false;
// Only check once per manifest build
if ($already_checked) {
return;
}
$already_checked = true;
// Get all manifest files
$files = \App\RSpade\Core\Manifest\Manifest::get_all();
if (empty($files)) {
return;
}
// Check PHP classes
$this->check_php_classes($files);
// Check JavaScript classes
$this->check_javascript_classes($files);
}
/**
* Check PHP classes for instance method violations
*/
private function check_php_classes(array $files): void
{
foreach ($files as $file => $metadata) {
// Skip if not a PHP class
if (!isset($metadata['class']) || !isset($metadata['fqcn'])) {
continue;
}
// Skip if no public instance methods
if (empty($metadata['public_instance_methods'])) {
continue;
}
// Check if this class is allowed to have instance methods
if ($this->is_php_class_instantiatable($metadata['fqcn'], $files)) {
continue;
}
// Found violation - class has instance methods but is not instantiatable
// Build remediation text based on whether class has parent
$parent_class = $metadata['extends'] ?? null;
$is_framework_file = str_starts_with($file, 'app/RSpade/');
if ($parent_class) {
$suggestion = "RSpade discourages creating classes solely to perform operations (utility/service pattern). " .
"Classes should be static unless each instance represents a distinct entity in the system. " .
"Recommended fixes:\n" .
"1. Convert all methods to static if this is a utility/service class\n" .
"2. If instances truly represent distinct objects (components, models, etc.):\n" .
" - STRONGLY RECOMMENDED: Add #[Instantiatable] to the parent class '{$parent_class}'\n" .
" - Only add #[Instantiatable] to this class if '{$parent_class}' should NOT be instantiatable";
// Add note for framework files
if ($is_framework_file) {
$suggestion .= "\n - NOTE: If '{$parent_class}' is a Laravel/vendor class, add #[Instantiatable] to THIS class instead";
}
} else {
$suggestion = "RSpade discourages creating classes solely to perform operations (utility/service pattern). " .
"Classes should be static unless each instance represents a distinct entity in the system. " .
"Recommended fixes:\n" .
"1. Convert all methods to static if this is a utility/service class\n" .
"2. If instances truly represent distinct objects (components, models, etc.):\n" .
" - Add #[Instantiatable] to this class";
}
foreach ($metadata['public_instance_methods'] as $method_name => $method_info) {
$line = $method_info['line'] ?? 1;
$this->add_violation(
$file,
$line,
"Instance method '{$method_name}' found in class '{$metadata['class']}'. RSpade classes should use static methods unless instances represent distinct objects (like UI components or ORM records).",
"public function {$method_name}(...)",
$suggestion,
'medium'
);
}
}
}
/**
* Check JavaScript classes for instance method violations
*/
private function check_javascript_classes(array $files): void
{
foreach ($files as $file => $metadata) {
// Skip if not a JavaScript class
if (!isset($metadata['class']) || ($metadata['extension'] ?? '') !== 'js') {
continue;
}
// Skip if no public instance methods
if (empty($metadata['public_instance_methods'])) {
continue;
}
// Check if this class is allowed to have instance methods
if ($this->is_js_class_instantiatable($metadata['class'], $files)) {
continue;
}
// Found violation - class has instance methods but is not instantiatable
// Build remediation text based on whether class has parent
$parent_class = $metadata['extends'] ?? null;
if ($parent_class) {
$suggestion = "RSpade discourages creating classes solely to perform operations (utility/service pattern). " .
"Classes should be static unless each instance represents a distinct entity in the system. " .
"Recommended fixes:\n" .
"1. Convert all methods to static if this is a utility/service class\n" .
"2. If instances truly represent distinct objects (components, models, etc.):\n" .
" - STRONGLY RECOMMENDED: Add @Instantiatable to the parent class '{$parent_class}' JSDoc\n" .
" - Only add @Instantiatable to this class's JSDoc if '{$parent_class}' should NOT be instantiatable";
} else {
$suggestion = "RSpade discourages creating classes solely to perform operations (utility/service pattern). " .
"Classes should be static unless each instance represents a distinct entity in the system. " .
"Recommended fixes:\n" .
"1. Convert all methods to static if this is a utility/service class\n" .
"2. If instances truly represent distinct objects (components, models, etc.):\n" .
" - Add @Instantiatable to this class's JSDoc";
}
foreach ($metadata['public_instance_methods'] as $method_name => $method_info) {
$line = $method_info['line'] ?? 1;
$this->add_violation(
$file,
$line,
"Instance method '{$method_name}' found in class '{$metadata['class']}'. RSpade classes should use static methods unless instances represent distinct objects (like UI components or ORM records).",
"{$method_name}(...)",
$suggestion,
'medium'
);
}
}
}
/**
* Check if a PHP class is instantiatable (has Instantiatable attribute in ancestry)
*/
private function is_php_class_instantiatable(string $fqcn, array $files): bool
{
// Check the class itself
try {
$metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_fqcn($fqcn);
if (isset($metadata['attributes']['Instantiatable'])) {
return true;
}
// Walk up the parent chain using manifest data
$current_fqcn = $fqcn;
$checked = []; // Prevent infinite loops
while (true) {
if (isset($checked[$current_fqcn])) {
break; // Already checked this class
}
$checked[$current_fqcn] = true;
try {
$current_metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_fqcn($current_fqcn);
$parent_class = $current_metadata['extends'] ?? null;
if (!$parent_class) {
break; // No more parents
}
// Get parent FQCN if we only have simple name
$parent_fqcn = $current_metadata['extends_fqcn'] ?? $parent_class;
// Check if parent has Instantiatable
try {
$parent_metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_fqcn($parent_fqcn);
if (isset($parent_metadata['attributes']['Instantiatable'])) {
return true;
}
$current_fqcn = $parent_fqcn; // Continue up the chain
} catch (\Exception $e) {
// Parent not in manifest, stop here
break;
}
} catch (\Exception $e) {
// Can't get metadata, stop
break;
}
}
} catch (\Exception $e) {
// Class not in manifest
return false;
}
return false;
}
/**
* Check if a JavaScript class is instantiatable (has Instantiatable decorator in ancestry)
*/
private function is_js_class_instantiatable(string $class_name, array $files): bool
{
// Find the class metadata
$class_metadata = null;
foreach ($files as $file => $metadata) {
if (($metadata['class'] ?? '') === $class_name && ($metadata['extension'] ?? '') === 'js') {
$class_metadata = $metadata;
break;
}
}
if (!$class_metadata) {
return false;
}
// Check the class itself
// Decorators are in compact format: [[name, [args]], ...]
if (isset($class_metadata['decorators'])) {
foreach ($class_metadata['decorators'] as $decorator) {
if (($decorator[0] ?? '') === 'Instantiatable') {
return true;
}
}
}
// Walk up the parent chain using manifest data
$current_class = $class_name;
$checked = []; // Prevent infinite loops
while (true) {
if (isset($checked[$current_class])) {
break; // Already checked this class
}
$checked[$current_class] = true;
// Find current class metadata
$current_metadata = null;
foreach ($files as $file => $metadata) {
if (($metadata['class'] ?? '') === $current_class && ($metadata['extension'] ?? '') === 'js') {
$current_metadata = $metadata;
break;
}
}
if (!$current_metadata) {
break; // Class not found
}
$parent_class = $current_metadata['extends'] ?? null;
if (!$parent_class) {
break; // No more parents
}
// Check if parent has Instantiatable
foreach ($files as $file => $metadata) {
if (($metadata['class'] ?? '') === $parent_class && ($metadata['extension'] ?? '') === 'js') {
// Check parent decorators in compact format
if (isset($metadata['decorators'])) {
foreach ($metadata['decorators'] as $decorator) {
if (($decorator[0] ?? '') === 'Instantiatable') {
return true;
}
}
}
$current_class = $parent_class; // Continue up the chain
break;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Manifest;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* MonoprogenicRule - Enforces single generation of concrete descendants
*
* Classes marked with #[Monoprogenic] attribute can only have one generation
* of concrete (non-abstract) children. This prevents inheritance ambiguity
* in reflection-based operations.
*
* Valid patterns:
* - Monoprogenic (abstract) -> Child (concrete)
* - Monoprogenic (abstract) -> Child (abstract) -> Grandchild (concrete)
*
* Invalid patterns:
* - Monoprogenic (abstract) -> Child (concrete) -> Grandchild (concrete)
*
* This rule prevents situations where concrete classes extend other concrete
* classes in a Monoprogenic hierarchy, which would create ambiguity about
* which class should handle operations discovered through reflection (such as
* routing, command discovery, or other framework operations).
*/
class Monoprogenic_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'MANIFEST-MONO-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Monoprogenic Inheritance Validation';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Enforces that #[Monoprogenic] classes only have one generation of concrete descendants';
}
/**
* Get file patterns this rule applies to
*
* Note: This returns PHP files, but the rule actually checks relationships
* across the entire manifest, not individual files
*/
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* This rule runs during manifest scan to validate inheritance patterns
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
/**
* Check the manifest for Monoprogenic violations
*
* This method is called once per file during manifest scan, but we only
* need to check the entire manifest once. We use a static flag to ensure
* the check only runs once per manifest build.
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
static $already_checked = false;
// Only check once per manifest build
if ($already_checked) {
return;
}
$already_checked = true;
// Get all manifest files
$files = \App\RSpade\Core\Manifest\Manifest::get_all();
if (empty($files)) {
return;
}
// Step 1: Find all classes with Monoprogenic attribute
$monoprogenic_classes = [];
foreach ($files as $file => $file_metadata) {
if (isset($file_metadata['attributes']['Monoprogenic'])) {
$fqcn = $file_metadata['fqcn'] ?? null;
if ($fqcn) {
$monoprogenic_classes[$fqcn] = $file;
}
}
}
if (empty($monoprogenic_classes)) {
return;
}
// Step 2: For each Monoprogenic class, check all its descendants
foreach ($monoprogenic_classes as $monoprogenic_class => $monoprogenic_file) {
$this->check_descendants($monoprogenic_class, $monoprogenic_file, $files);
}
}
/**
* Check all descendants of a Monoprogenic class for violations
*/
private function check_descendants(string $monoprogenic_class, string $monoprogenic_file, array $files): void
{
// Find all classes that extend from any Monoprogenic class
foreach ($files as $file => $metadata) {
$fqcn = $metadata['fqcn'] ?? null;
if (!$fqcn || !class_exists($fqcn)) {
continue;
}
// Check if this class is a subclass of the Monoprogenic class
if (!is_subclass_of($fqcn, $monoprogenic_class)) {
continue;
}
// Check if the class is abstract
if (\App\RSpade\Core\Manifest\Manifest::php_is_abstract($fqcn)) {
continue; // Abstract classes are allowed at any level
}
// This is a concrete class that extends from Monoprogenic
// Check if its direct parent is also concrete (violation)
// Get the parent class name from manifest data
$class_metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_fqcn($fqcn);
$parent_class_name = $class_metadata['extends'] ?? null;
if (!$parent_class_name) {
continue;
}
// If parent is the Monoprogenic class itself, that's fine
if ($parent_class_name === $monoprogenic_class) {
continue;
}
// Check if parent is abstract
if (!\App\RSpade\Core\Manifest\Manifest::php_is_abstract($parent_class_name)) {
// VIOLATION: Concrete class extending another concrete class in Monoprogenic hierarchy
$line_number = $this->find_class_line($file, $metadata['class'] ?? '');
$this->add_violation(
$file,
$line_number,
sprintf(
"Class '%s' violates Monoprogenic inheritance pattern.\n\n" .
"This class is a concrete (non-abstract) class that extends another concrete class '%s'.\n" .
"The base class '%s' has the #[Monoprogenic] attribute, which means concrete descendants " .
"can only extend from it through abstract intermediary classes.\n\n" .
"Monoprogenic classes are used in reflection-based operations (like route discovery or " .
"command registration). Having concrete classes extend other concrete classes creates " .
"ambiguity about which class should handle the discovered operations.",
$metadata['class'] ?? $fqcn,
$this->get_short_name($parent_class_name),
$this->get_short_name($monoprogenic_class)
),
"class " . ($metadata['class'] ?? '') . " extends",
sprintf(
"To fix this violation, choose one of these approaches:\n" .
"1. Make the parent class '%s' abstract\n" .
"2. Make this class '%s' extend directly from an abstract class\n" .
"3. Remove the concrete inheritance chain by not extending '%s'",
$this->get_short_name($parent_class_name),
$metadata['class'] ?? '',
$this->get_short_name($parent_class_name)
),
'critical'
);
}
}
}
/**
* Find the line number where a class is declared
*/
private function find_class_line(string $file_path, string $class_name): int
{
$absolute_path = base_path($file_path);
if (!file_exists($absolute_path)) {
return 1;
}
$contents = file_get_contents($absolute_path);
$lines = explode("\n", $contents);
foreach ($lines as $index => $line) {
if (preg_match('/^\s*(?:abstract\s+)?class\s+' . preg_quote($class_name) . '\b/', $line)) {
return $index + 1;
}
}
return 1;
}
/**
* Get short class name from FQCN
*/
private function get_short_name(string $fqcn): string
{
$parts = explode('\\', $fqcn);
return end($parts);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Manifest;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* RsxControllerInheritanceRule - Enforces proper controller inheritance in RSX
*
* RSX controller classes must extend Rsx_Controller_Abstract or one of its subclasses,
* not Laravel's base Controller class. This ensures RSX controllers have access to
* RSX-specific functionality and follow the framework's conventions.
*/
class RsxControllerInheritance_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'MANIFEST-CTRL-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'RSX Controller Inheritance Validation';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Enforces that RSX controllers extend Rsx_Controller_Abstract, not Laravel\'s Controller';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* This rule runs during manifest scan
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
/**
* Check for RSX controllers extending Laravel's Controller
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
static $already_checked = false;
// Only check once per manifest build
if ($already_checked) {
return;
}
$already_checked = true;
// Get all manifest files
$files = \App\RSpade\Core\Manifest\Manifest::get_all();
if (empty($files)) {
return;
}
// Check each PHP class in ./rsx directory
foreach ($files as $file => $file_metadata) {
// Only check PHP files with classes in ./rsx directory
if (!isset($file_metadata['class'])) {
continue;
}
if (!str_starts_with($file, 'rsx/')) {
continue;
}
// Check if this class directly extends "Controller"
$extends = $file_metadata['extends'] ?? null;
if ($extends !== 'Controller') {
continue;
}
// Found violation - RSX controller extending Laravel's Controller
// Find line number of class declaration
$line = $this->find_class_line($file, $file_metadata['class']);
$this->add_violation(
$file,
$line,
"RSX controller class '{$file_metadata['class']}' extends Laravel's Controller class. " .
"RSX controllers must extend Rsx_Controller_Abstract or one of its subclasses to access " .
"RSX-specific functionality and follow framework conventions.",
"class {$file_metadata['class']} extends Controller",
"Change the parent class from 'Controller' to 'Rsx_Controller_Abstract':\n" .
" class {$file_metadata['class']} extends Rsx_Controller_Abstract",
'high'
);
}
}
/**
* Find the line number where a class is declared
*/
private function find_class_line(string $file_path, string $class_name): int
{
$absolute_path = base_path($file_path);
if (!file_exists($absolute_path)) {
return 1;
}
$contents = file_get_contents($absolute_path);
$lines = explode("\n", $contents);
foreach ($lines as $index => $line) {
if (preg_match('/^\s*(?:abstract\s+)?class\s+' . preg_quote($class_name) . '\b/', $line)) {
return $index + 1;
}
}
return 1;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Meta;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Rule: META-INHERIT-01
*
* Detects incorrect class inheritance checking in code quality rules.
* Code quality rules should use Manifest::php_is_subclass_of() instead of regex patterns
* to check if a class extends another, since regex can't detect indirect inheritance.
*
* Example of incorrect pattern:
* preg_match('/class\s+\w+\s+extends\s+Jqhtml_Component/', $contents)
*
* Should be:
* Manifest::php_is_subclass_of($class_name, 'Jqhtml_Component')
*/
class Code_Quality_Meta_Inheritance_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'META-INHERIT-01';
}
public function get_name(): string
{
return 'Code Quality Meta: Incorrect Inheritance Checking';
}
public function get_description(): string
{
return 'Detects code quality rules using regex to check class inheritance instead of Manifest::php_is_subclass_of(). ' .
'Regex patterns cannot detect indirect inheritance (A extends B extends C) and should be replaced with Manifest-based checks.';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
public function is_called_during_manifest_scan(): bool
{
return false;
}
/**
* Check code quality rule files for incorrect inheritance checking patterns
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check code quality rule files
if (!str_contains($file_path, '/CodeQuality/Rules/')) {
return;
}
// Skip this meta rule itself
if (basename($file_path) === 'Code_Quality_Meta_Inheritance_CodeQualityRule.php') {
return;
}
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
$line_lower = strtolower($line);
// Look for lines that appear to be using regex to check inheritance
// These typically contain 'match' (from preg_match), 'class', and 'extends'
// Allow for backslash escapes in the regex pattern
if (str_contains($line_lower, 'match') &&
str_contains($line_lower, 'class') &&
str_contains($line_lower, 'extends')) {
// Additional checks to reduce false positives
// Look for specific patterns that indicate regex inheritance checking
// The \\\\ matches literal backslash in PHP regex strings
if (preg_match('/preg_match.*class.*extends/i', $line)) {
$this->add_violation(
$file_path,
$line_number,
'Code quality rule appears to be using regex to check class inheritance. ' .
'This will fail for indirect inheritance (A extends B extends C). ' .
'Use Manifest methods instead for accurate inheritance checking.',
trim($line),
"If checking PHP classes: Use Manifest::php_is_subclass_of(\$class_name, 'BaseClass')\n" .
"If checking JavaScript classes: Use Manifest::js_is_subclass_of(\$class_name, 'BaseClass')\n" .
"Examples:\n" .
" PHP: if (Manifest::php_is_subclass_of(\$metadata['class'], 'Rsx_Model_Abstract')) { ... }\n" .
" JS: if (Manifest::js_is_subclass_of(\$class_name, 'Jqhtml_Component')) { ... }",
'high'
);
}
}
// Also check for str_contains/strpos patterns checking for 'extends'
// These are equally problematic for inheritance checking
if ((str_contains($line, 'str_contains') || str_contains($line, 'strpos')) &&
str_contains($line_lower, 'extends')) {
// Check if this looks like inheritance checking
if (preg_match('/str_(?:contains|pos)\s*\([^,]+,\s*[\'"]extends/', $line)) {
$this->add_violation(
$file_path,
$line_number,
'Code quality rule appears to be using string search to check class inheritance. ' .
'This will fail for indirect inheritance and is not reliable. ' .
'Use Manifest methods instead.',
trim($line),
"If checking PHP classes: Use Manifest::php_is_subclass_of(\$class_name, 'BaseClass')\n" .
"If checking JavaScript classes: Use Manifest::js_is_subclass_of(\$class_name, 'BaseClass')",
'high'
);
}
}
}
}
}

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'
);
}
}
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class ClassExists_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-CLASS-01';
}
public function get_name(): string
{
return 'class_exists() Usage Check';
}
public function get_description(): string
{
return 'Enforces predictable runtime - no conditional class checking except for sanity checks';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check PHP file for class_exists() usage
* Allows exception if followed by Exception( or shouldnt_happen( within 2 lines
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor directories
if (str_contains($file_path, '/vendor/')) {
return;
}
// Skip CodeQuality and SchemaQuality directories - they check rule classes exist
if (str_contains($file_path, '/CodeQuality/') || str_contains($file_path, '/SchemaQuality/')) {
return;
}
// Skip InspectCommand.php - it documents what the checks do
if (str_contains($file_path, 'InspectCommand.php')) {
return;
}
// Skip Autoloader.php - it needs to check class existence as part of its core functionality
if (str_contains($file_path, 'Autoloader.php')) {
return;
}
// Skip Manifest.php - it needs to check if classes are loaded during manifest building
if (str_contains($file_path, 'Manifest.php')) {
return;
}
// Skip ManifestDumpCommand.php - it checks for optional dependencies
if (str_contains($file_path, 'ManifestDumpCommand.php')) {
return;
}
// Skip AttributeProcessor.php - attributes may not have backing classes per CLAUDE.md
if (str_contains($file_path, 'AttributeProcessor.php')) {
return;
}
// Skip Model_ManifestSupport.php - runs during manifest building when models may not be loadable
if (str_contains($file_path, 'Model_ManifestSupport.php')) {
return;
}
// Skip BundleCompiler.php - checks for bundle module classes
if (str_contains($file_path, 'BundleCompiler.php')) {
return;
}
// Skip files that need Redis class_exists checks for IDE context
// These files check if Redis extension exists when running in IDE context
if (str_contains($file_path, 'RsxLocks.php') ||
str_contains($file_path, 'RsxCache.php')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version (was a comment)
if (trim($sanitized_line) === '') {
continue;
}
// Check for class_exists( usage
if (preg_match('/\bclass_exists\s*\(/i', $sanitized_line)) {
// Check if it's part of a sanity check (has Exception or shouldnt_happen within 2 lines)
$is_sanity_check = false;
// Check current line and next 2 lines for Exception( or shouldnt_happen(
// Also check original lines to see if there's a comment followed by exception
for ($i = 0; $i <= 2; $i++) {
$check_line_num = $line_num + $i;
// Check sanitized line for exception patterns
if (isset($sanitized_lines[$check_line_num])) {
$check_line = $sanitized_lines[$check_line_num];
if (preg_match('/\b(throw\s+new\s+)?[A-Za-z]*Exception\s*\(/i', $check_line) ||
preg_match('/\bshouldnt_happen\s*\(/i', $check_line)) {
$is_sanity_check = true;
break;
}
}
// Also check original line to see if there's a comment explaining it's a sanity check
if (isset($original_lines[$check_line_num])) {
$orig_line = $original_lines[$check_line_num];
// Check if line contains comment indicating sanity check
if (preg_match('/\/\/.*sanity check/i', $orig_line) ||
preg_match('/\/\/.*should(n\'t| not) happen/i', $orig_line)) {
// Check if next line has exception
if (isset($sanitized_lines[$check_line_num + 1])) {
$next_line = $sanitized_lines[$check_line_num + 1];
if (preg_match('/\b(throw\s+new\s+)?[A-Za-z]*Exception\s*\(/i', $next_line) ||
preg_match('/\bshouldnt_happen\s*\(/i', $next_line)) {
$is_sanity_check = true;
break;
}
}
}
}
}
if (!$is_sanity_check) {
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$this->add_violation(
$file_path,
$line_number,
'class_exists() is not allowed. The runtime environment is strict and predictable - all expected classes will exist.',
trim($original_line),
"Analyze the usage and apply the appropriate fix:\n\n" .
"1. SANITY CHECK (most common): If verifying a class that should exist:\n" .
" - Must be followed by exception within 2 lines\n" .
" - Use: if (!class_exists(\$class)) { shouldnt_happen('Class should exist'); }\n" .
" - Or: if (!class_exists(\$class)) { throw new \\Exception('...'); }\n\n" .
"2. DEFENSIVE CODING: If checking before using a class:\n" .
" - Remove the check entirely - the class will exist or PHP will fail loudly\n" .
" - Trust the autoloader and framework\n\n" .
"3. DISCOVERY/REFLECTION: If dynamically finding classes:\n" .
" - Use: Manifest::php_find_class(\$simple_name) for discovery\n" .
" - Use: Manifest::php_get_extending(\$base_class) for finding subclasses\n\n" .
"4. CONDITIONAL BEHAVIOR: If doing different things based on class availability:\n" .
" - Refactor to not depend on class existence\n" .
" - Use configuration or feature flags instead\n\n" .
'The framework guarantees all expected classes are available. Defensive class_exists() checks ' .
'hide errors that should fail loudly during development.',
'high'
);
}
}
}
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
use App\RSpade\Core\Manifest\Manifest;
/**
* Check that all Route methods in Rsx_Controller_Abstract descendants are static
* Note: This is somewhat redundant since only static methods are extracted,
* but provides a belt-and-suspenders check for safety
*/
class ControllerRouteStatic_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONTROLLER-STATIC-01';
}
public function get_name(): string
{
return 'Controller Route Methods Must Be Static';
}
public function get_description(): string
{
return 'Ensures all Route methods in RSX controllers are static';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Run during manifest build for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if no class or no FQCN
if (!isset($metadata['fqcn'])) {
return;
}
// Check if this class extends Rsx_Controller_Abstract
if (!$this->is_controller_descendant($metadata['fqcn'])) {
return;
}
// Check all public static methods for Route attributes
if (!isset($metadata['public_static_methods'])) {
return;
}
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
// Skip if no attributes
if (!isset($method_data['attributes'])) {
continue;
}
// Check if method has Route attribute
$has_route = false;
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) {
$has_route = true;
break;
}
}
// This check is redundant since we only extract public_static_methods
// But we keep it for belt-and-suspenders safety
if ($has_route && (!isset($method_data['static']) || !$method_data['static'])) {
// This should never happen since manifest only extracts static methods
shouldnt_happen("Method {$method_name} in public_static_methods is not marked as static in {$file_path}. This indicates a serious bug in metadata extraction.");
}
}
// Also check for Route attributes on non-static methods by parsing the file
// This catches the actual error case
$this->check_non_static_routes($file_path, $contents, $metadata);
}
private function check_non_static_routes(string $file_path, string $contents, array $metadata): void
{
// Quick check if file contains Route attributes at all
if (!preg_match('/#\[Route\b/', $contents)) {
return;
}
// Parse for non-static methods with Route attributes
$lines = explode("\n", $contents);
$in_class = false;
$current_attributes = [];
for ($i = 0; $i < count($lines); $i++) {
$line = trim($lines[$i]);
// Track when we're inside a class
if (preg_match('/^class\s+\w+/', $line)) {
$in_class = true;
}
if (!$in_class) {
continue;
}
// Collect attributes
if (preg_match('/^#\[(Route\b[^\]]*)\]/', $line, $matches)) {
$current_attributes[] = $matches[1];
continue;
}
// Check for method definition
if (preg_match('/^(public|protected|private)\s+(?!static)(?:function\s+)?(\w+)\s*\(/', $line, $matches)) {
// Non-static method found
if (!empty($current_attributes)) {
// Has Route attribute but not static
$method_name = $matches[2];
$this->throw_non_static_route($file_path, $method_name, $metadata['class'] ?? 'Unknown');
}
}
// Clear attributes if we hit a method or property
if (preg_match('/^(public|protected|private)\s+/', $line)) {
$current_attributes = [];
}
}
}
private function is_controller_descendant(string $fqcn): bool
{
// Check if class is loaded
if (!class_exists($fqcn, false)) {
// Try to load from manifest metadata
try {
$metadata = Manifest::php_get_metadata_by_fqcn($fqcn);
$parent = $metadata['extends'] ?? null;
// Walk up the inheritance chain
while ($parent) {
if ($parent === 'Rsx_Controller_Abstract' ||
$parent === 'App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract') {
return true;
}
// Try to get parent's metadata
try {
$parent_metadata = Manifest::php_get_metadata_by_class($parent);
$parent = $parent_metadata['extends'] ?? null;
} catch (\Exception $e) {
// Parent not found in manifest
break;
}
}
} catch (\Exception $e) {
// Class not found in manifest
return false;
}
} else {
// Class is loaded, use reflection
$reflection = new \ReflectionClass($fqcn);
return $reflection->isSubclassOf('App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract');
}
return false;
}
private function throw_non_static_route(string $file_path, string $method_name, string $class_name): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Route method must be static\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Class: {$class_name}\n";
$error_message .= "Method: {$method_name}\n\n";
$error_message .= "All Route methods in RSX controllers must be static.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "The method '{$method_name}' has a #[Route] attribute but is not static.\n";
$error_message .= "RSX routing system can only dispatch to static methods.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " #[Route('/users')]\n";
$error_message .= " public function list_users(Request \$request) { ... }\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " #[Route('/users')]\n";
$error_message .= " public static function list_users(Request \$request, array \$params = []) { ... }\n\n";
$error_message .= "WHY THIS MATTERS:\n";
$error_message .= "- RSX controllers are never instantiated\n";
$error_message .= "- Routes are dispatched directly to static methods\n";
$error_message .= "- This allows stateless, efficient request handling\n\n";
$error_message .= "FIX:\n";
$error_message .= "Add the 'static' keyword to the method definition.\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\Core\Database\ModelHelper;
class DbTableUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-DB-01';
}
public function get_name(): string
{
return 'DB::table() Usage Check';
}
public function get_description(): string
{
return 'Enforces ORM pattern - no direct query builder access via DB::table()';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for DB::table() usage and enforce ORM model usage (from line 1814)
* Enforces ORM pattern - no direct query builder access via DB::table()
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor directories
if (str_contains($file_path, '/vendor/')) {
return;
}
// Skip migration files - they legitimately use DB::table() for schema operations
if (str_contains($file_path, '/database/migrations/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Skip InspectCommand.php - it documents what the checks do
if (str_contains($file_path, 'InspectCommand.php')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version (was a comment)
if (trim($sanitized_line) === '') {
continue;
}
// Check for DB::table( usage in sanitized content
if (preg_match('/DB\s*::\s*table\s*\(/i', $sanitized_line)) {
// Try to extract table name from the parameter (use sanitized line)
$table_name = null;
if (preg_match('/DB\s*::\s*table\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/i', $sanitized_line, $matches)) {
$table_name = $matches[1];
}
// Skip Laravel's internal migrations table
if ($table_name === 'migrations') {
continue;
}
// Skip Laravel's sessions table - managed by framework
if ($table_name === 'sessions') {
continue;
}
// Use original line for display in error message
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Determine which directory to suggest for model creation
$model_location = str_contains($file_path, '/rsx/') ? './rsx/models' : './app/Models';
// Build resolution message based on whether we found the table name
$resolution = "Direct database table access via DB::table() violates framework architecture principles.\n\n";
if ($table_name) {
// Check if a model exists for this table
$model_exists = false;
$model_class = null;
try {
$model_class = ModelHelper::get_model_by_table($table_name);
$model_exists = true;
} catch (\Exception $e) {
// Model doesn't exist
$model_exists = false;
}
if ($model_exists) {
$resolution .= "RECOMMENDED SOLUTION:\n";
$resolution .= "Use the existing '{$model_class}' model instead of DB::table('{$table_name}').\n";
$resolution .= "Example: {$model_class}::where('column', \$value)->get();\n\n";
} else {
$resolution .= "RECOMMENDED SOLUTION:\n";
$resolution .= "No model detected for table '{$table_name}'. Create a new model class extending Rsx_Model_Abstract in {$model_location}.\n";
$resolution .= "Example model definition:\n";
$resolution .= " class " . ucfirst(str_replace('_', '', ucwords($table_name, '_'))) . " extends Rsx_Model_Abstract {\n";
$resolution .= " protected \$table = '{$table_name}';\n";
$resolution .= " }\n\n";
}
} else {
$resolution .= "RECOMMENDED SOLUTION:\n";
$resolution .= "Create an ORM model extending Rsx_Model_Abstract in {$model_location} for the target table.\n\n";
}
$resolution .= "ALTERNATIVE (for complex reporting queries only):\n";
$resolution .= "If ORM is genuinely inappropriate due to query complexity (e.g., multi-table aggregations, complex reporting):\n";
$resolution .= "- Use DB::select() with raw SQL and prepared parameters for read operations\n";
$resolution .= "- Use DB::statement() with prepared parameters for write operations\n";
$resolution .= "Example: DB::select('SELECT * FROM table WHERE id = ?', [\$id]);\n\n";
$resolution .= "RATIONALE:\n";
$resolution .= "- ORM models provide data integrity, relationships, and business logic encapsulation\n";
$resolution .= "- Query builder (DB::table()) offers no advantages over raw queries for complex operations\n";
$resolution .= "- Consistent use of models maintains architectural coherence";
$this->add_violation(
$file_path,
$line_number,
"DB::table() usage detected. Framework requires ORM models for database access.",
trim($original_line),
$resolution,
'high'
);
}
}
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class ExecUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-EXEC-01';
}
public function get_name(): string
{
return 'exec() Usage Check';
}
public function get_description(): string
{
return 'Prohibits exec() function due to silent output truncation - requires proc_open() or shell_exec()';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check PHP file for exec() usage
*
* exec() has a critical limitation: it reads command output line-by-line into an array,
* which can cause silent truncation for large outputs or hit memory/buffer limits.
*
* This causes catastrophic failures where:
* - Compilation output gets truncated mid-line
* - Error messages are incomplete
* - No error/exception is thrown - the truncation is SILENT
*
* Requires proc_open() (for return code validation) or shell_exec() (simple cases).
*/
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;
}
// Skip InspectCommand.php - it documents what the checks do
if (str_contains($file_path, 'InspectCommand.php')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments and strings removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version (was a comment)
if (trim($sanitized_line) === '') {
continue;
}
// Check for exec( usage - word boundary ensures we don't match "execute(" etc.
if (preg_match('/\bexec\s*\(/i', $sanitized_line)) {
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$violation_message = "🚨 CRITICAL: exec() function detected - causes SILENT OUTPUT TRUNCATION
exec() has a fundamental flaw: it reads command output LINE-BY-LINE into an array, which:
- Hits memory/buffer limits on large outputs (>1MB typical)
- Silently truncates output without throwing errors or exceptions
- Causes catastrophic failures in compilation, bundling, and error reporting
- Makes debugging impossible (you see partial output with no indication of truncation)
Real-world example from this codebase:
- jqhtml compilation of 220-line template was truncated at row 4 (mid-line)
- No error thrown, no indication of failure
- Took hours to diagnose because the truncation was SILENT
- Fixed by replacing exec() with proc_open() - output jumped from 4KB to 35KB
This is why exec() is BANNED across the entire application.";
$resolution = "REQUIRED ACTION - Choose based on your needs:
QUICKEST FIX (Drop-in replacement - no refactoring needed):
Use \exec_safe() - RSpade framework helper with identical signature to exec():
\exec_safe(\$command, \$output, \$return_var);
Simply replace exec() with \exec_safe(). That's it. No other code changes needed.
Uses proc_open() internally to handle unlimited output without truncation.
Example:
// Before:
exec('git status 2>&1', \$output, \$code);
// After:
\exec_safe('git status 2>&1', \$output, \$code);
Benefits:
- Zero refactoring - maintains exact same signature as exec()
- All existing code continues to work identically
- No silent truncation (uses proc_open() internally)
- Framework helper function available everywhere
FOR ADVANCED USERS (Need full control):
Use proc_open() which streams unlimited output without size limits:
\$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'] // stderr
];
\$process = proc_open(\$command, \$descriptors, \$pipes);
if (!is_resource(\$process)) {
throw new \\RuntimeException(\"Failed to execute command\");
}
fclose(\$pipes[0]); // Close stdin
\$output_str = stream_get_contents(\$pipes[1]); // Read all stdout
\$error_str = stream_get_contents(\$pipes[2]); // Read all stderr
fclose(\$pipes[1]);
fclose(\$pipes[2]);
\$return_code = proc_close(\$process);
// Combine stderr with stdout if command failed
if (\$return_code !== 0 && !empty(\$error_str)) {
\$output_str = \$error_str . \"\\n\" . \$output_str;
}
if (\$return_code !== 0) {
throw new \\RuntimeException(\"Command failed: {\$output_str}\");
}
Benefits:
- Streams unlimited output (no size limits)
- Separate stdout/stderr streams
- Proper return code validation
- No silent truncation
FOR SIMPLE CASES (Don't need return code):
Use shell_exec() for commands where you only need output:
\$output = shell_exec(\$command);
if (\$output === null) {
throw new \\RuntimeException(\"Command failed\");
}
Benefits:
- Simple one-line replacement
- Returns entire output as string (no line-by-line buffering)
- No silent truncation
- Drawback: Cannot get return code (assumes success if output is not null)
RATIONALE:
exec() was designed for simple command execution in the early PHP days. Modern PHP
applications with large compilation outputs, bundling systems, and complex toolchains
need proper stream handling. Both proc_open() and shell_exec() handle
unlimited output correctly - exec() does not.
NEVER use exec() for:
- Compilation outputs (esbuild, webpack, babel, jqhtml)
- Bundler commands
- Any command with potentially large output (>100 lines)
- Commands where you need to see complete error messages";
$this->add_violation(
$file_path,
$line_number,
$violation_message,
trim($original_line),
$resolution,
'critical'
);
}
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class FunctionExists_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-FUNC-01';
}
public function get_name(): string
{
return 'function_exists() Usage Check';
}
public function get_description(): string
{
return 'Enforces predictable runtime - no conditional function checking';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check PHP file for function_exists() usage (from line 1652)
* Enforces predictable runtime - no conditional function checking
*/
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;
}
// Skip InspectCommand.php - it documents what the checks do
if (str_contains($file_path, 'InspectCommand.php')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version (was a comment)
if (trim($sanitized_line) === '') {
continue;
}
// Check for function_exists( usage
if (preg_match('/\bfunction_exists\s*\(/i', $sanitized_line)) {
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$this->add_violation(
$file_path,
$line_number,
"function_exists() is not allowed. The runtime environment is strict and predictable - all expected functions will exist.",
trim($original_line),
"Remove function_exists() checks. Never conditionally define functions or handle situations differently based on what functions are defined. If a function doesn't exist, let PHP throw an error. For example, never check for ImageMagick and fall back to GD - assume ImageMagick exists and let it fail if the environment is misconfigured.",
'high'
);
}
}
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class GenericSuffix_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-MODULE-01';
}
public function get_name(): string
{
return 'Generic Suffix Naming Convention';
}
public function get_description(): string
{
return 'Classes with generic suffixes like "Module" or "Rule" must use more descriptive compound suffixes';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check PHP files for classes with generic suffixes that should be more descriptive
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip SchemaQuality and CodeQuality directories - they have their own naming conventions
if (str_contains($file_path, 'app/RSpade/SchemaQuality/') ||
str_contains($file_path, 'app/RSpade/CodeQuality/')) {
return;
}
// Skip -temp files
if (str_contains($file_path, '-temp.')) {
return;
}
// Get configured generic suffixes
$generic_suffixes = config('rsx.code_quality.generic_suffix_replacements', []);
if (empty($generic_suffixes)) {
return;
}
// Get sanitized content
$sanitized_data = FileSanitizer::sanitize_php($contents);
$content = $sanitized_data['content'];
// Extract namespace
$namespace = null;
if (preg_match('/^\s*namespace\s+([^;]+);/m', $content, $matches)) {
$namespace = $matches[1];
}
// Find class declarations
// @META-INHERIT-01-EXCEPTION - Extracting class names and positions, not checking inheritance
// This extracts class declarations from source code for naming convention checks
preg_match_all('/^\s*(?:abstract\s+)?class\s+([A-Za-z0-9_]+)(?:\s+extends\s+([A-Za-z0-9_\\\\]+))?/m', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
foreach ($matches as $match) {
$class_name = $match[1][0];
$extends_class = isset($match[2]) ? $match[2][0] : null;
$offset = $match[0][1];
$is_abstract = str_starts_with(trim($match[0][0]), 'abstract');
// Count line number
$line_number = substr_count(substr($content, 0, $offset), "\n") + 1;
// PRIORITY 1: Check abstract classes with pattern (Prefix)_(GenericSuffix)_Abstract
// E.g., Schema_Rule_Abstract should be SchemaRule_Abstract
if ($is_abstract) {
foreach ($generic_suffixes as $suffix => $better_suffixes) {
// Check for pattern: Something_Rule_Abstract or Something_Module_Abstract
$pattern = '/^(.+)_(' . preg_quote($suffix, '/') . ')_Abstract$/';
if (preg_match($pattern, $class_name, $abstract_match)) {
$prefix = $abstract_match[1];
$generic_suffix = $abstract_match[2];
// Suggest combining prefix with suffix
$suggested_name = $prefix . $generic_suffix . '_Abstract';
$this->add_violation(
$file_path,
$line_number,
"Abstract class '{$class_name}' uses generic suffix '{$generic_suffix}' that should be combined with prefix",
trim($match[0][0]),
"Abstract classes with generic suffixes like '{$generic_suffix}' should combine the prefix with the suffix to form a compound name.\n\n" .
"Current: {$class_name}\n" .
"Suggested: {$suggested_name}\n\n" .
"The suffix '{$generic_suffix}' by itself is too generic. Combine it with '{$prefix}' to create a more descriptive compound suffix.",
'high'
);
break;
}
}
continue; // Skip further checks for abstract classes
}
// PRIORITY 2: Check classes ending with compound suffixes (must extend correct abstract)
// E.g., Blade_ManifestModule should extend ManifestModule_Abstract
$found_compound_suffix = null;
foreach ($generic_suffixes as $base_suffix => $better_suffixes) {
foreach ($better_suffixes as $compound_suffix) {
$pattern = '/_' . preg_quote($compound_suffix, '/') . '$/i';
if (preg_match($pattern, $class_name)) {
$found_compound_suffix = $compound_suffix;
break 2;
}
}
}
if ($found_compound_suffix) {
// Check if it extends an abstract class
if (!$extends_class) {
$this->add_violation(
$file_path,
$line_number,
"Class '{$class_name}' ends with '{$found_compound_suffix}' but doesn't extend any abstract class",
trim($match[0][0]),
"Classes with compound suffixes must extend the corresponding abstract class.\n\n" .
"For a class ending in '{$found_compound_suffix}', it should extend '{$found_compound_suffix}_Abstract'.\n" .
"This ensures consistent interface and behavior across all types.",
'high'
);
continue;
}
// Check if the parent class matches the expected abstract class
$expected_abstract = $found_compound_suffix . '_Abstract';
// Remove namespace prefix if present
$parent_class_name = $extends_class;
if (str_contains($parent_class_name, '\\')) {
$parts = explode('\\', $parent_class_name);
$parent_class_name = end($parts);
}
if ($parent_class_name !== $expected_abstract) {
$this->add_violation(
$file_path,
$line_number,
"Class '{$class_name}' extends '{$extends_class}' but should extend '{$expected_abstract}'",
trim($match[0][0]),
"Classes must extend the abstract class matching their suffix.\n\n" .
"Class suffix: {$found_compound_suffix}\n" .
"Expected parent: {$expected_abstract}\n" .
"Actual parent: {$parent_class_name}\n\n" .
"This ensures type safety and consistent behavior.",
'high'
);
}
continue; // Skip generic suffix checks for compound suffixes
}
// PRIORITY 3: Check for generic suffixes that need proper underscore separation
// E.g., TestModule should be Test_Module
foreach ($generic_suffixes as $suffix => $better_suffixes) {
// Check if class name contains this suffix anywhere
if (!str_contains($class_name, $suffix)) {
continue;
}
// Check if class name contains suffix but without proper underscores
// E.g., TestModule should be Test_Module, MyRule should be My_Rule
$pattern = '/[a-z]' . preg_quote($suffix, '/') . '|' . preg_quote($suffix, '/') . '[A-Z]/';
if (preg_match($pattern, $class_name)) {
$suggested_name = preg_replace('/([a-z])' . preg_quote($suffix, '/') . '/', '$1_' . $suffix, $class_name);
$suggested_name = preg_replace('/' . preg_quote($suffix, '/') . '([A-Z])/', $suffix . '_$1', $suggested_name);
$this->add_violation(
$file_path,
$line_number,
"Class '{$class_name}' contains '{$suffix}' but lacks proper underscore separation",
trim($match[0][0]),
"Classes containing '{$suffix}' should use underscores for proper word separation.\n\n" .
"Current: {$class_name}\n" .
"Suggested: {$suggested_name}\n\n" .
"However, note that classes ending with just '_{$suffix}' are too generic.\n" .
"Consider a more specific suffix like '_{$better_suffixes[0]}' or '_{$better_suffixes[1]}'.",
'high'
);
break; // Only report one violation per class
}
// PRIORITY 4: Check if class ends with just "_{$suffix}" (too generic)
$generic_pattern = '/_' . preg_quote($suffix, '/') . '$/i';
if (preg_match($generic_pattern, $class_name)) {
// Extract the prefix before _{$suffix}
$prefix = preg_replace($generic_pattern, '', $class_name);
// Build example suggestions
$examples = "";
foreach ($better_suffixes as $better_suffix) {
$examples .= " - {$prefix}_{$better_suffix}\n";
}
$this->add_violation(
$file_path,
$line_number,
"Class '{$class_name}' has generic '_{$suffix}' suffix without a specific type",
trim($match[0][0]),
"Classes ending with '_{$suffix}' are too generic. The suffix should describe the specific type.\n\n" .
"Current: {$class_name}\n" .
"Examples of better names:\n" .
$examples . "\n" .
"The suffix should indicate what kind of {$suffix} this is.",
'high'
);
break; // Only report one violation per class
}
}
}
}
}

View File

@@ -0,0 +1,345 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*
* @ROUTE-EXISTS-01-EXCEPTION - This file generates code templates with placeholder route names
*/
namespace App\RSpade\CodeQuality\Rules\PHP;
use Illuminate\Support\Facades\Route;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Dispatch\Dispatcher;
use App\RSpade\Core\Manifest\Manifest;
/**
* HardcodedUrlInRedirectRule - Detect hardcoded URLs in redirect responses
*
* This rule scans PHP controller files for redirect() calls with hardcoded
* internal URLs and suggests using proper route generation methods instead.
*/
class HardcodedUrlInRedirect_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique identifier for this rule
*
* @return string
*/
public function get_id(): string
{
return 'PHP-REDIRECT-01';
}
/**
* Get the default severity level
*
* @return string One of: critical, high, medium, low, convention
*/
public function get_default_severity(): string
{
return 'high';
}
/**
* Get the file patterns this rule applies to
*
* @return array
*/
public function get_file_patterns(): array
{
return ['*_controller.php', '*_Controller.php', '*Controller.php'];
}
/**
* Get the display name for this rule
*
* @return string
*/
public function get_name(): string
{
return 'Hardcoded URL in Redirect';
}
/**
* Get the description of what this rule checks
*
* @return string
*/
public function get_description(): string
{
return 'Detects hardcoded URLs in redirect() responses and suggests using route generation';
}
/**
* Check the file contents for violations
*
* @param string $file_path The path to the file being checked
* @param string $contents The contents of the file
* @param array $metadata Additional metadata about the file
* @return void
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check PHP controller files
if (!str_ends_with($file_path, '.php')) {
return;
}
// Initialize manifest to ensure routes are available
try {
Manifest::init();
} catch (\Exception $e) {
// If manifest fails to initialize, we can't check routes
return;
}
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
// Skip commented lines
if (preg_match('/^\s*\/\//', $line) || preg_match('/^\s*\*/', $line)) {
continue;
}
// Look for redirect patterns with hardcoded URLs
// Patterns to match:
// redirect('/path')
// redirect()->to('/path')
// Redirect::to('/path')
// redirect()->route('/path') - incorrect usage
$patterns = [
'/\bredirect\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
'/\bredirect\s*\(\s*\)\s*->\s*to\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
'/\bRedirect\s*::\s*to\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
'/\bredirect\s*\(\s*\)\s*->\s*route\s*\(\s*[\'"]\/([^\'"]+)[\'"]\s*\)/', // Catch misuse of route()
];
foreach ($patterns as $pattern) {
if (preg_match_all($pattern, $line, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[1] as $match) {
$url = $match[0];
$position = $match[1];
// For the route() misuse pattern, add back the leading /
if (strpos($pattern, '->route') !== false) {
$url = '/' . $url;
}
// Check if this is a likely internal route
if (!$this->_is_likely_internal_route($url)) {
continue;
}
// Extract base URL and query params
$url_parts = parse_url($url);
$base_url = $url_parts['path'] ?? '/';
$query_string = $url_parts['query'] ?? '';
// Try to resolve to RSX route first
$route_info = null;
try {
$route_info = Dispatcher::resolve_url_to_route($base_url, 'GET');
} catch (\Exception $e) {
// URL doesn't resolve to an RSX route
}
$suggested_code = '';
if ($route_info) {
// Found RSX route
$controller_class = $route_info['class'] ?? '';
$method_name = $route_info['method'] ?? '';
$route_params = $route_info['params'] ?? [];
// Parse query string params
$query_params = [];
if ($query_string) {
parse_str($query_string, $query_params);
}
// Merge all params
$all_params = array_merge($query_params, $route_params);
// Extract just the class name without namespace
$class_parts = explode('\\', $controller_class);
$class_name = end($class_parts);
$suggested_code = $this->_generate_rsx_suggestion($class_name, $method_name, $all_params);
} else {
// Check if it's a Laravel route
$laravel_route = $this->_find_laravel_route($base_url);
if ($laravel_route) {
$suggested_code = $this->_generate_laravel_suggestion($laravel_route, $query_string);
} else {
// No route found, skip
continue;
}
}
// Add violation
$this->add_violation(
$line_num + 1,
$position,
"Hardcoded URL in redirect: {$url}",
$line,
"Use route generation instead:\n{$suggested_code}"
);
}
}
}
}
}
/**
* Check if a URL is likely an internal route
*
* @param string $url
* @return bool
*/
protected function _is_likely_internal_route(string $url): bool
{
// Must start with /
if (!str_starts_with($url, '/')) {
return false;
}
// Skip absolute URLs (with protocol)
if (preg_match('#^//#', $url)) {
return false;
}
// Extract path before query string
$path = strtok($url, '?');
// Get the last segment of the path
$segments = explode('/', trim($path, '/'));
$last_segment = end($segments);
// If last segment has a dot (file extension), it's likely a file not a route
if ($last_segment && str_contains($last_segment, '.')) {
return false;
}
// Skip common static asset paths
$static_prefixes = ['/assets/', '/css/', '/js/', '/images/', '/img/', '/fonts/', '/storage/'];
foreach ($static_prefixes as $prefix) {
if (str_starts_with($path, $prefix)) {
return false;
}
}
return true;
}
/**
* Find Laravel route by URL
*
* @param string $url
* @return string|null Route name if found
*/
protected function _find_laravel_route(string $url): ?string
{
// Get all Laravel routes
$routes = Route::getRoutes();
foreach ($routes as $route) {
// Check if URL matches this route's URI
if ($route->uri() === ltrim($url, '/')) {
// Get the route name if it has one
$name = $route->getName();
if ($name) {
return $name;
}
// No name, but route exists - return the URI for direct use
return $url;
}
}
return null;
}
/**
* Generate RSX route suggestion
*
* @param string $class_name
* @param string $method_name
* @param array $params
* @return string
*/
protected function _generate_rsx_suggestion(string $class_name, string $method_name, array $params): string
{
if (empty($params)) {
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}')->url());";
} else {
$params_str = $this->_format_php_array($params);
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}')->url({$params_str}));";
}
}
/**
* Generate Laravel route suggestion
*
* @param string $route_name
* @param string $query_string
* @return string
*/
protected function _generate_laravel_suggestion(string $route_name, string $query_string): string
{
// If route_name starts with /, it means no named route exists
if (str_starts_with($route_name, '/')) {
// Suggest adding a name to the route
$suggested_name = $this->_suggest_route_name($route_name);
return "return redirect(route('{$suggested_name}'));\n// First add ->name('{$suggested_name}') to the route definition in routes/web.php";
}
// Route has a name, use it
if ($query_string) {
$query_params = [];
parse_str($query_string, $query_params);
$params_str = $this->_format_php_array($query_params);
return "return redirect(route('{$route_name}', {$params_str}));";
} else {
return "return redirect(route('{$route_name}'));";
}
}
/**
* Suggest a route name based on the URL path
*
* @param string $url
* @return string
*/
protected function _suggest_route_name(string $url): string
{
// Remove leading slash and convert to dot notation
$path = ltrim($url, '/');
// Convert path segments to route name
// /test-bundle-facade => test.bundle.facade
// /_idehelper => idehelper
$path = str_replace('_', '', $path); // Remove leading underscores
$path = str_replace('-', '.', $path); // Convert dashes to dots
$path = str_replace('/', '.', $path); // Convert slashes to dots
return $path ?: 'home';
}
/**
* Format a PHP array for display
*
* @param array $params
* @return string
*/
protected function _format_php_array(array $params): string
{
$items = [];
foreach ($params as $key => $value) {
$key_str = var_export($key, true);
$value_str = var_export($value, true);
$items[] = "{$key_str} => {$value_str}";
}
return '[' . implode(', ', $items) . ']';
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Detects Laravel date calculations that should use MySQL date functions
*
* This rule identifies when now()->sub() or now()->add() is used to calculate dates
* that are then passed to database queries. These should use MySQL's
* DATE_SUB() or DATE_ADD() functions instead for better performance,
* accuracy, and timezone consistency.
*/
class LaravelDateCalculation_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-DATE-CALC-01';
}
public function get_name(): string
{
return 'Laravel Date Calculation in DB Queries';
}
public function get_description(): string
{
return 'Detects Laravel date calculations that should use MySQL date functions in queries';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* This rule supports checking Console Commands
*/
public function supports_console_commands(): bool
{
return true;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip non-PHP files
if (!str_ends_with($file_path, '.php')) {
return;
}
$lines = explode("\n", $contents);
// Track variables assigned with now()->sub/add calculations
$date_calc_vars = [];
// Pattern to match now()->subX() or now()->addX() assignments
// Captures: $variable = now()->subHours(24)->format(...)
$assignment_pattern = '/\$(\w+)\s*=\s*now\(\)\s*->\s*(sub|add)(Years?|Months?|Weeks?|Days?|Hours?|Minutes?|Seconds?)\s*\(\s*(\d+)\s*\)(?:\s*->\s*format\s*\([^)]+\))?/i';
// Also check for Carbon::now() variant
$carbon_pattern = '/\$(\w+)\s*=\s*Carbon::now\(\)\s*->\s*(sub|add)(Years?|Months?|Weeks?|Days?|Hours?|Minutes?|Seconds?)\s*\(\s*(\d+)\s*\)(?:\s*->\s*format\s*\([^)]+\))?/i';
// Scan for date calculation assignments
foreach ($lines as $line_num => $line) {
if (preg_match($assignment_pattern, $line, $matches) || preg_match($carbon_pattern, $line, $matches)) {
$var_name = $matches[1];
$operation = $matches[2]; // sub or add
$unit = $matches[3]; // Hours, Days, etc.
$amount = $matches[4];
// Store the variable info with its line number
$date_calc_vars[$var_name] = [
'line' => $line_num + 1,
'operation' => $operation,
'unit' => $unit,
'amount' => $amount,
'code' => trim($line)
];
}
}
// If no date calculations found, nothing to check
if (empty($date_calc_vars)) {
return;
}
// Now look for usage of these variables in DB queries
foreach ($lines as $line_num => $line) {
foreach ($date_calc_vars as $var_name => $var_info) {
// Check if this variable is used in a DB:: query
if (str_contains($line, 'DB::') && str_contains($line, '$' . $var_name)) {
$this->report_violation($file_path, $line_num + 1, $line, $var_info);
}
// Check for Eloquent query usage (where, whereDate, etc.)
if (preg_match('/->where(Date|Time|Year|Month|Day)?\s*\([^,)]*\$' . preg_quote($var_name) . '/i', $line)) {
$this->report_violation($file_path, $line_num + 1, $line, $var_info);
}
// Check for raw query bindings
if (preg_match('/\[\s*\$' . preg_quote($var_name) . '\s*[\],]/', $line)) {
// Check if this line or nearby lines contain SQL keywords
$context = implode(' ', array_slice($lines, max(0, $line_num - 2), 5));
if (preg_match('/\b(SELECT|INSERT|UPDATE|DELETE|WHERE)\b/i', $context)) {
$this->report_violation($file_path, $line_num + 1, $line, $var_info);
}
}
}
}
}
/**
* Report a violation for using Laravel date calculation in DB query
*/
private function report_violation(string $file_path, int $line_num, string $line, array $var_info): void
{
// Convert Laravel time unit to MySQL interval unit
$mysql_unit = $this->get_mysql_interval_unit($var_info['unit']);
$operation = strtoupper($var_info['operation']); // SUB or ADD
$suggestion = "Use MySQL date functions instead of Laravel date calculations.\n\n";
$suggestion .= "Replace the date calculation on line {$var_info['line']}:\n";
$suggestion .= " {$var_info['code']}\n\n";
$suggestion .= "With direct MySQL date function in your query:\n";
$suggestion .= " DATE_{$operation}(NOW(), INTERVAL {$var_info['amount']} {$mysql_unit})\n\n";
$suggestion .= "Example:\n";
$suggestion .= " WHERE updated_at < DATE_{$operation}(NOW(), INTERVAL {$var_info['amount']} {$mysql_unit})\n\n";
$suggestion .= "Benefits:\n";
$suggestion .= " - Better performance (calculation in database)\n";
$suggestion .= " - Timezone consistency (database handles it)\n";
$suggestion .= " - More accurate (no time gap between calculation and query)";
$this->add_violation(
$file_path,
$line_num,
"Laravel date calculation used in database query",
trim($line),
$suggestion,
'medium'
);
}
/**
* Convert Laravel/Carbon time unit to MySQL INTERVAL unit
*/
private function get_mysql_interval_unit(string $laravel_unit): string
{
// Remove trailing 's' for singular and convert to MySQL format
$unit = rtrim(strtoupper($laravel_unit), 'S');
// MySQL uses singular forms for INTERVAL units
switch ($unit) {
case 'YEAR':
return 'YEAR';
case 'MONTH':
return 'MONTH';
case 'WEEK':
return 'WEEK';
case 'DAY':
return 'DAY';
case 'HOUR':
return 'HOUR';
case 'MINUTE':
return 'MINUTE';
case 'SECOND':
return 'SECOND';
default:
return $unit;
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class LaravelSession_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-SESSION-01';
}
public function get_name(): string
{
return 'Laravel Session Usage Check';
}
public function get_description(): string
{
return 'Prohibits Laravel session() usage in RSpade applications - enforces RSpade Session:: methods';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for session() calls in RSX directory and suggest RSpade alternatives
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor directories
if (str_contains($file_path, '/vendor/')) {
return;
}
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments and strings to avoid false positives
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') ||
str_starts_with($trimmed_line, '#') ||
str_starts_with($trimmed_line, '*') ||
str_starts_with($trimmed_line, '/*')) {
continue;
}
// Skip lines that contain session() only in strings or comments
$line_without_strings = preg_replace('/["\'].*?["\']/', '', $line);
$line_without_comments = preg_replace('/\/\/.*$/', '', $line_without_strings);
$line_without_comments = preg_replace('/\/\*.*?\*\//', '', $line_without_comments);
// Look for session() function calls in the cleaned line
if (preg_match('/\bsession\s*\(/', $line_without_comments)) {
$code_snippet = trim($line);
// Determine specific suggestion based on usage pattern
$suggestion = $this->get_specific_suggestion($line);
$this->add_violation(
$file_path,
$line_number,
"Laravel session() usage is not allowed in RSX applications. Use RSpade Session:: methods instead.",
$code_snippet,
$suggestion,
$this->get_default_severity()
);
}
}
}
/**
* Get specific replacement suggestion based on the session() usage pattern
*/
private function get_specific_suggestion(string $line): string
{
// Check for flash usage specifically
if (preg_match('/session\(\)\s*->\s*flash\s*\(/', $line)) {
return "Replace session()->flash() with RSpade flash alert methods:\n" .
"• Rsx::flash_success(\$message) - for success messages\n" .
"• Rsx::flash_error(\$message) - for error messages\n" .
"• Rsx::flash_warning(\$message) - for warning messages\n" .
"• Rsx::flash_alert(\$message, \$class) - for custom alerts";
}
// Check for common session operations
if (preg_match('/session\(\)\s*->\s*get\s*\(/', $line)) {
return "Replace session()->get() with RSpade Session methods:\n" .
"• Session::get_user() - get current user\n" .
"• Session::get_site() - get current site\n" .
"• Session::get_user_id() - get current user ID\n" .
"• Session::get_site_id() - get current site ID";
}
if (preg_match('/session\(\)\s*->\s*(put|set)\s*\(/', $line)) {
return "Replace session()->put() with RSpade Session methods:\n" .
"• Session::set_user_id(\$user_id) - set current user\n" .
"• Session::set_site_id(\$site_id) - set current site\n" .
"• For other session data, consider if it should be stored in the database instead";
}
if (preg_match('/session\(\)\s*->\s*forget\s*\(/', $line)) {
return "Replace session()->forget() with appropriate RSpade Session methods:\n" .
"• Session::logout() - for user logout\n" .
"• Session::clear_user() - to clear user data\n" .
"• Session::clear_site() - to clear site data";
}
// Generic suggestion for other session() usage
return "Replace session() with RSpade Session methods:\n" .
"• Session::get_user() - get current user\n" .
"• Session::get_site() - get current site\n" .
"• Session::get_user_id() - get current user ID\n" .
"• Session::get_site_id() - get current site ID\n" .
"• Session::set_user_id(\$user_id) - set current user\n" .
"• Session::set_site_id(\$site_id) - set current site\n" .
"• Rsx::flash_success/error/warning(\$message) - for flash messages";
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class MassAssignment_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-MASS-01';
}
public function get_name(): string
{
return 'Mass Assignment Property Check';
}
public function get_description(): string
{
return 'Prohibits use of $fillable and enforces $guarded = ["*"] in models';
}
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
* mass assignment protection is critical for security and must be enforced immediately.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update to extract mass assignment metadata
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
// Skip Model_Abstract itself
if (str_contains($file_path, 'Model_Abstract.php')) {
return null;
}
// Skip Rsx_Model_Abstract.php - it needs empty $fillable to satisfy Laravel requirements
if (str_contains($file_path, 'Rsx_Model_Abstract.php')) {
return null;
}
// Skip if not a model file
if (!isset($metadata['class'])) {
return null;
}
// Check if this is a model using proper inheritance check
if (!Manifest::php_is_subclass_of($metadata['class'], 'Rsx_Model_Abstract')) {
return null; // Not a model class
}
// Parse the file to check for mass assignment properties
$parser = (new ParserFactory())->createForNewestSupportedVersion();
try {
$ast = $parser->parse($contents);
if (!$ast) {
return null;
}
} catch (\Exception $e) {
return null;
}
$nodeFinder = new NodeFinder();
$properties = $nodeFinder->findInstanceOf($ast, Node\Stmt\Property::class);
$violations = [];
foreach ($properties as $property) {
foreach ($property->props as $prop) {
$prop_name = $prop->name->toString();
// Check for $fillable property
if ($prop_name === 'fillable') {
$violations[] = [
'type' => 'fillable',
'line' => $property->getLine(),
'property' => $prop_name
];
}
// Check for $guarded property (unless it's set to ['*'])
if ($prop_name === 'guarded') {
$is_star_guarded = false;
if ($prop->default instanceof Node\Expr\Array_) {
if (count($prop->default->items) === 1) {
$item = $prop->default->items[0];
if ($item && $item->value instanceof Node\Scalar\String_ && $item->value->value === '*') {
$is_star_guarded = true;
}
}
}
if (!$is_star_guarded) {
$violations[] = [
'type' => 'guarded',
'line' => $property->getLine(),
'property' => $prop_name
];
}
}
}
}
if (!empty($violations)) {
return ['mass_assignment_violations' => $violations];
}
return null;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip Model_Abstract itself
if (str_contains($file_path, 'Model_Abstract.php')) {
return;
}
// Skip Rsx_Model_Abstract.php - it needs empty $fillable to satisfy Laravel requirements
if (str_contains($file_path, 'Rsx_Model_Abstract.php')) {
return;
}
// Skip if not a model file
if (!isset($metadata['class'])) {
return;
}
// Check if this is a model using proper inheritance check
if (!Manifest::php_is_subclass_of($metadata['class'], 'Rsx_Model_Abstract')) {
return; // Not a model class
}
// Check for mass assignment violations in code quality metadata
if (isset($metadata['code_quality_metadata']['PHP-MASS-01']['mass_assignment_violations'])) {
$violations = $metadata['code_quality_metadata']['PHP-MASS-01']['mass_assignment_violations'];
// Throw on first violation
foreach ($violations as $violation) {
$type = $violation['type'];
$line = $violation['line'];
$class_name = $metadata['class'];
if ($type === 'fillable') {
$error_message = "Code Quality Violation (PHP-MASS-01) - Prohibited Mass Assignment Property\n\n";
$error_message .= "Model class '{$class_name}' has a \$fillable property\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$line}\n\n";
$error_message .= "CRITICAL: Mass assignment is prohibited in RSX.\n\n";
$error_message .= "Resolution:\n";
$error_message .= "Remove the \$fillable property and assign fields explicitly:\n";
$error_message .= "\$model->field = \$value;\n";
$error_message .= "\$model->save();\n\n";
$error_message .= "This ensures data integrity and security by requiring explicit field assignment.";
} else {
$error_message = "Code Quality Violation (PHP-MASS-01) - Incorrect Guard Configuration\n\n";
$error_message .= "Model class '{$class_name}' has a customized \$guarded property\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$line}\n\n";
$error_message .= "CRITICAL: The \$guarded property should not be customized.\n\n";
$error_message .= "Resolution:\n";
$error_message .= "Remove the \$guarded property entirely.\n";
$error_message .= "Model_Abstract handles mass assignment protection automatically.";
}
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
$line
);
}
}
}
}

View File

@@ -0,0 +1,386 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use PhpParser\Error;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use ReflectionClass;
use ReflectionMethod;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class NamingConvention_CodeQualityRule extends CodeQualityRule_Abstract
{
protected static $parser = null;
public function get_id(): string
{
return 'PHP-NAMING-01';
}
public function get_name(): string
{
return 'PHP Method Naming Convention';
}
public function get_description(): string
{
return 'Enforces underscore_case naming convention for PHP methods';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* 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
{
// Only apply naming convention to files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
try {
$ast = $this->get_parser()->parse($contents);
// Get the class name from the AST
$class_name = $this->get_class_name($ast);
if ($class_name) {
// Try to use reflection if the class is loadable
$this->check_with_reflection($class_name, $file_path);
} else {
// Fall back to AST-only checking
$this->check_methods($ast, $file_path);
}
} catch (Error $error) {
// Parse error - skip this file for naming checks
return;
}
}
/**
* Get the fully qualified class name from AST
*/
protected function get_class_name($ast)
{
$nodeFinder = new NodeFinder;
// Find namespace
$namespace_node = $nodeFinder->findFirstInstanceOf($ast, Node\Stmt\Namespace_::class);
$namespace = '';
if ($namespace_node && $namespace_node->name) {
$namespace = $namespace_node->name->toString();
}
// Find class
$class_node = $nodeFinder->findFirstInstanceOf($ast, Node\Stmt\Class_::class);
if ($class_node && $class_node->name) {
$class_name = $class_node->name->toString();
return $namespace ? $namespace . '\\' . $class_name : $class_name;
}
return null;
}
/**
* Check using reflection to identify overridden methods
*/
protected function check_with_reflection($class_name, $file_path)
{
// Ensure the class is loaded
if (!class_exists($class_name)) {
// Try to require the file once
try {
require_once $file_path;
} catch (\Exception $e) {
// If we can't load it, fall back to AST checking
$code = file_get_contents($file_path);
$ast = $this->get_parser()->parse($code);
$this->check_methods($ast, $file_path);
return;
}
}
if (!class_exists($class_name)) {
// Still can't load, fall back to AST
$code = file_get_contents($file_path);
$ast = $this->get_parser()->parse($code);
$this->check_methods($ast, $file_path);
return;
}
try {
$reflection = new ReflectionClass($class_name);
// Check methods
foreach ($reflection->getMethods() as $method) {
// Skip methods not declared in this class
if ($method->getDeclaringClass()->getName() !== $class_name) {
continue;
}
$method_name = $method->getName();
// Skip if this method exists in a parent class (it's an override)
if ($this->is_overridden_method($reflection, $method_name)) {
continue;
}
// Skip magic methods
if (strpos($method_name, '__') === 0) {
continue;
}
// Skip private/protected methods that start with underscore
if (strpos($method_name, '_') === 0) {
continue;
}
// Skip Laravel convention methods
if ($this->is_laravel_method($method_name)) {
continue;
}
// Check if method name follows underscore_case
if (!$this->is_underscore_case($method_name)) {
$this->add_violation(
$file_path,
$method->getStartLine(),
"Method '{$method_name}' should use underscore_case naming convention",
null,
"Rename to: " . $this->to_underscore_case($method_name),
'medium'
);
}
}
} catch (\ReflectionException $e) {
// Fall back to AST checking if reflection fails
$code = file_get_contents($file_path);
$ast = $this->get_parser()->parse($code);
$this->check_methods($ast, $file_path);
}
}
/**
* Check if a method is overriding a parent method
*/
protected function is_overridden_method(ReflectionClass $class, $method_name)
{
$parent = $class->getParentClass();
while ($parent) {
// Check for the method in parent
if ($parent->hasMethod($method_name)) {
// Make sure we're checking the right scope (static vs instance)
try {
$parent_method = $parent->getMethod($method_name);
$our_method = $class->getMethod($method_name);
// If both are static or both are instance, it's an override
if ($parent_method->isStatic() === $our_method->isStatic()) {
return true;
}
} catch (\ReflectionException $e) {
// If we can't get the method, assume it's an override to be safe
return true;
}
}
$parent = $parent->getParentClass();
}
// Also check interfaces
foreach ($class->getInterfaces() as $interface) {
if ($interface->hasMethod($method_name)) {
return true;
}
}
// Also check traits
foreach ($class->getTraits() as $trait) {
if ($trait->hasMethod($method_name)) {
return true;
}
}
return false;
}
/**
* Fallback*: Check methods in the AST for naming violations (when reflection not available)
*/
protected function check_methods($ast, $file_path)
{
$nodeFinder = new NodeFinder;
// Find all class methods
$methods = $nodeFinder->findInstanceOf($ast, Node\Stmt\ClassMethod::class);
foreach ($methods as $method) {
$method_name = $method->name->toString();
// Skip magic methods
if (strpos($method_name, '__') === 0) {
continue;
}
// Skip private/protected methods that start with underscore
if (strpos($method_name, '_') === 0) {
continue;
}
// Skip Laravel convention methods
if ($this->is_laravel_method($method_name)) {
continue;
}
// Check if method name follows underscore_case
if (!$this->is_underscore_case($method_name)) {
$visibility = $this->get_visibility($method);
$this->add_violation(
$file_path,
$method->getLine(),
"Method '{$method_name}' should use underscore_case naming convention",
null,
"Rename to: " . $this->to_underscore_case($method_name),
'medium'
);
}
}
}
/**
* Check if method name follows Laravel conventions that require camelCase
*/
protected function is_laravel_method($method_name)
{
// Eloquent query scopes - must start with 'scope' followed by PascalCase
if (preg_match('/^scope[A-Z]/', $method_name)) {
return true;
}
// Eloquent accessors - get{Property}Attribute
if (preg_match('/^get[A-Z].*Attribute$/', $method_name)) {
return true;
}
// Eloquent mutators - set{Property}Attribute
if (preg_match('/^set[A-Z].*Attribute$/', $method_name)) {
return true;
}
// Common Laravel framework methods that must keep their names
$laravel_methods = [
// Eloquent methods
'firstOrCreate', 'firstOrNew', 'updateOrCreate', 'findOrFail', 'findOrNew',
'newInstance', 'newQuery', 'newEloquentBuilder', 'newCollection',
'toArray', 'toJson', 'jsonSerialize',
// Notification methods
'toMail', 'toDatabase', 'toBroadcast', 'toNexmo', 'toSlack',
// Request methods
'prepareForValidation', 'passedValidation', 'failedValidation',
// Resource methods
'toResponse',
// Service Provider methods
'registerPolicies', 'registerCommands', 'registerEvents',
'registerFactories', 'registerMiddleware', 'registerObservers',
'registerBladeDirectives', 'registerRsxExtendsPrecompiler',
// Controller methods
'callAction', 'missingMethod',
// Job methods
'backoff', 'retryUntil',
];
if (in_array($method_name, $laravel_methods)) {
return true;
}
// Methods that start with common Laravel prefixes
$laravel_prefixes = [
'with', 'has', 'can', 'is', 'was', 'will', 'should',
'register', 'boot', 'bind', 'singleton',
];
foreach ($laravel_prefixes as $prefix) {
if (preg_match('/^' . $prefix . '[A-Z]/', $method_name)) {
// Only if it matches Laravel's typical pattern
if (in_array($method_name, [
'withTrashed', 'withoutTrashed', 'onlyTrashed',
'hasMany', 'hasOne', 'belongsTo', 'belongsToMany',
'morphTo', 'morphMany', 'morphOne', 'morphToMany',
'registerPolicies', 'registerCommands', 'registerEvents',
'bootTraits',
])) {
return true;
}
}
}
return false;
}
/**
* Check if a string follows underscore_case convention
*/
protected function is_underscore_case($string)
{
// Single word methods are fine
if (!preg_match('/[A-Z]/', $string)) {
return true;
}
// Check for camelCase or PascalCase
if (preg_match('/[a-z][A-Z]/', $string) || preg_match('/^[A-Z]/', $string)) {
return false;
}
return true;
}
/**
* Convert a string to underscore_case
*/
protected function to_underscore_case($string)
{
// Convert camelCase/PascalCase to underscore_case
$string = preg_replace('/(?<!^)[A-Z]/', '_$0', $string);
return strtolower($string);
}
/**
* Get visibility from AST method node
*/
protected function get_visibility($method)
{
if ($method->isPublic()) return 'public';
if ($method->isProtected()) return 'protected';
if ($method->isPrivate()) return 'private';
return 'public'; // Default
}
}

View File

@@ -0,0 +1,282 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use Exception;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class NoImplements_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-IMPLEMENTS-01';
}
public function get_name(): string
{
return 'No Implements Keyword';
}
public function get_description(): string
{
return 'Prohibits use of implements keyword except for Model subclasses';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check for prohibited use of implements keyword
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check files in ./rsx and ./app/RSpade
$is_rsx = str_contains($file_path, '/rsx/');
$is_rspade = str_contains($file_path, '/app/RSpade/');
if (!$is_rsx && !$is_rspade) {
return;
}
// Get class information from metadata
if (!isset($metadata['class'])) {
return;
}
$class_name = $metadata['class'];
// Check if this class is a subclass of Model (allowed to use implements)
$is_model_subclass = false;
// Check direct extends
if (isset($metadata['extends'])) {
$extends = $metadata['extends'];
if ($extends === 'Model' || $extends === 'Rsx_Model_Abstract') {
$is_model_subclass = true;
} else {
// Check inheritance chain
try {
if (Manifest::php_is_subclass_of($class_name, 'Model') ||
Manifest::php_is_subclass_of($class_name, 'Rsx_Model_Abstract')) {
$is_model_subclass = true;
}
} catch (Exception $e) {
// Class not in manifest, continue checking
}
}
}
// If it's a Model subclass, implements is allowed
if ($is_model_subclass) {
return;
}
// Check if class has implements
if (!isset($metadata['implements']) || empty($metadata['implements'])) {
return;
}
$implements = $metadata['implements'];
// Now check if any of the implemented interfaces are from ./rsx or ./app/RSpade
$local_interfaces = [];
$vendor_interfaces = [];
// PHP built-in interfaces that are allowed
$php_builtin_interfaces = [
'Throwable', 'Stringable', 'JsonSerializable', 'Serializable',
'ArrayAccess', 'Countable', 'Iterator', 'IteratorAggregate',
'Traversable', 'DateTimeInterface', 'OuterIterator',
'RecursiveIterator', 'SeekableIterator', 'SessionHandlerInterface',
];
// Get the original file content to extract use statements
$original_content = file_get_contents($file_path);
$use_statements = $this->extract_use_statements($original_content);
foreach ($implements as $interface) {
// Check if it's a PHP built-in interface
if (in_array($interface, $php_builtin_interfaces)) {
$vendor_interfaces[] = $interface;
continue;
}
// Check if it's a fully qualified name
if (str_contains($interface, '\\')) {
// Already FQCN
$fqcn = $interface;
} else {
// Look up in use statements
$fqcn = $this->resolve_class_name($interface, $use_statements, $metadata['namespace'] ?? '');
}
// Check if this interface is from local code
if (str_starts_with($fqcn, 'Rsx\\') || str_starts_with($fqcn, 'App\\RSpade\\')) {
$local_interfaces[] = $interface;
} else {
$vendor_interfaces[] = $interface;
}
}
// If there are no local interfaces, no violation
if (empty($local_interfaces)) {
return;
}
// Generate violation for local interface usage
$lines = explode("\n", $contents);
$line_number = 0;
$code_snippet = '';
// Find the line with class declaration and implements
foreach ($lines as $i => $line) {
if (preg_match('/\bclass\s+' . preg_quote($class_name) . '\b.*\bimplements\b/i', $line)) {
$line_number = $i + 1;
$code_snippet = $this->get_code_snippet($lines, $i);
break;
}
}
$resolution = "CRITICAL: The 'implements' keyword is not allowed in RSpade framework code.\n\n";
$resolution .= "INTERFACES DETECTED:\n";
$resolution .= '- Local interfaces (PROHIBITED): ' . implode(', ', $local_interfaces) . "\n";
if (!empty($vendor_interfaces)) {
$resolution .= '- Vendor interfaces (allowed): ' . implode(', ', $vendor_interfaces) . "\n";
}
$resolution .= "\n";
$resolution .= "WHY THIS IS PROHIBITED:\n";
$resolution .= "The RSpade framework philosophy requires using abstract classes instead of interfaces because:\n";
$resolution .= "1. Abstract classes can provide default implementations, reducing boilerplate\n";
$resolution .= "2. Abstract classes can have protected helper methods for subclasses\n";
$resolution .= "3. Single inheritance enforces simpler, more maintainable design\n";
$resolution .= "4. If a class needs multiple behaviors, it indicates the class is too complex\n\n";
$resolution .= "HOW TO FIX:\n";
$resolution .= "1. Convert the interface to an abstract class:\n";
$resolution .= " - Change 'interface' to 'abstract class'\n";
$resolution .= " - Add default implementations where appropriate\n";
$resolution .= " - Add protected helper methods if needed\n\n";
$resolution .= "2. Update this class to extend the abstract class:\n";
$resolution .= " - Change 'implements " . implode(', ', $local_interfaces) . "' to 'extends <AbstractClassName>'\n";
$resolution .= " - If the class already extends something, you need to refactor\n\n";
$resolution .= "3. If the class truly needs multiple behaviors:\n";
$resolution .= " - This indicates the class has too many responsibilities\n";
$resolution .= " - Break it into separate classes with single responsibilities\n";
$resolution .= " - Use composition instead of inheritance where appropriate\n\n";
$resolution .= "EXCEPTION:\n";
$resolution .= "Model classes (subclasses of Eloquent Model or Rsx_Model_Abstract) ARE allowed to implement interfaces\n";
$resolution .= "because they often need to implement Laravel contracts like Authenticatable, etc.\n\n";
$resolution .= "If this is genuinely needed (extremely rare), add '@PHP-IMPLEMENTS-01-EXCEPTION' comment.";
$this->add_violation(
$file_path,
$line_number,
"Class '{$class_name}' uses 'implements' keyword with local interfaces: " . implode(', ', $local_interfaces),
$code_snippet,
$resolution,
'critical'
);
}
/**
* Extract use statements from PHP file content
*/
protected function extract_use_statements(string $content): array
{
$use_statements = [];
// Match use statements
if (preg_match_all('/^\s*use\s+([^;]+);/m', $content, $matches)) {
foreach ($matches[1] as $use_statement) {
// Handle aliased imports
if (str_contains($use_statement, ' as ')) {
list($fqcn, $alias) = explode(' as ', $use_statement);
$fqcn = trim($fqcn);
$alias = trim($alias);
$use_statements[$alias] = $fqcn;
} else {
$fqcn = trim($use_statement);
// Get the simple name from FQCN
$parts = explode('\\', $fqcn);
$simple_name = end($parts);
$use_statements[$simple_name] = $fqcn;
}
}
}
return $use_statements;
}
/**
* Resolve a simple class name to FQCN using use statements
*/
protected function resolve_class_name(string $simple_name, array $use_statements, string $namespace): string
{
// Check if it's in use statements
if (isset($use_statements[$simple_name])) {
return $use_statements[$simple_name];
}
// If not in use statements and we have a namespace, assume it's in the same namespace
if ($namespace) {
return $namespace . '\\' . $simple_name;
}
// Otherwise return as-is
return $simple_name;
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class PhpFallbackLegacy_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-FALLBACK-01';
}
public function get_name(): string
{
return 'PHP Fallback/Legacy Code Check';
}
public function get_description(): string
{
return 'Enforces fail-loud principle - no fallback implementations allowed';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check PHP file for fallback/legacy code in comments and function calls (from line 1474)
* Enforces fail-loud principle - no fallback implementations allowed
*/
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;
}
// Skip RsxCheckCommand.php - it documents what the checks do
if (str_contains($file_path, 'RsxCheckCommand.php')) {
return;
}
// Use original content to check comments
$original_content = file_get_contents($file_path);
$lines = explode("\n", $original_content);
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip line if it's entirely a comment (starts with // after whitespace)
$trimmed_line = trim($line);
$is_line_comment = str_starts_with($trimmed_line, '//');
// Check for fallback or legacy in comments (case insensitive, with word boundaries)
// But allow fallback* or legacy* as marked exceptions
if (preg_match('/\/\/.*\b(fallback|legacy)\b(?!\*)/i', $line) ||
preg_match('/\/\*.*\b(fallback|legacy)\b(?!\*)/i', $line) ||
preg_match('/\*.*\b(fallback|legacy)\b(?!\*)/i', $line) ||
preg_match('/#.*\b(fallback|legacy)\b(?!\*)/i', $line)) { // Also check # comments
$this->add_violation(
$file_path,
$line_number,
"🚨 CRITICAL ARCHITECTURE VIOLATION: 'fallback' or 'legacy' detected - STOP IMMEDIATELY\n\nThis is NOT just a naming issue. The presence of 'fallback/legacy' often indicates:\n1. DEAD CODE handling conditions that no longer occur\n2. DUPLICATE IMPLEMENTATIONS still partially present\n3. INCOMPLETE MIGRATION from old to new systems\n4. VIOLATION TYPE HANDLERS for deprecated systems",
trim($line),
"REQUIRED IMMEDIATE ACTIONS:\n1. INVESTIGATE: Is this code actually reachable? Run grep for the condition it handles\n2. TRACE: What system/feature does this reference? Does it still exist anywhere?\n3. SEARCH: Are there duplicate implementations? (grep for related terms)\n4. VERIFY: If it's display code, does the violation type still get produced?\n\nFor AI/LLM agents: This is a P0 STOP-THE-WORLD violation. You MUST:\n- Immediately search for what triggers this code path\n- Report ALL findings to the user before making ANY changes\n- NEVER just rename or mark with asterisk without investigation\n- If dead code found, remove entirely (don't comment out)\n\nRemember: Dead fallback code is technical debt that causes debugging nightmares.\nEvery 'fallback' is a potential duplicate implementation waiting to cause PTSD.\n\nTo mark legitimate uses (extremely rare), add '*' after the word.",
'critical'
);
}
// Check for dual implementation patterns in single-line comments
// These patterns suggest old/new dual implementations which violate the no-fallback rule
if (preg_match('/\/\/.*\bold\W+.*\bnew\b/i', $line) || // "old" ... "new"
preg_match('/\/\/.*\bprevious\W+.*\bnew\b/i', $line) || // "previous" ... "new"
preg_match('/\/\/.*\bbackwards?\s+compatability/i', $line)) { // "backward(s) compatibility" (common misspelling)
$this->add_violation(
$file_path,
$line_number,
"🚨 CRITICAL DUAL IMPLEMENTATION VIOLATION: Comment suggests old/new or backwards compatibility patterns\n\nThis violates the fundamental architecture principle: There is NO 'old' and 'new' way - there is ONLY THE WAY.\nDual implementations cause:\n1. CONFUSION about which code path executes\n2. DUPLICATE CODE that diverges over time\n3. DEBUGGING NIGHTMARES when behavior differs\n4. MAINTENANCE BURDEN keeping both paths in sync",
trim($line),
"IMMEDIATE ACTION REQUIRED:\n1. IDENTIFY what 'old' vs 'new' refers to\n2. REMOVE the old implementation entirely\n3. UPDATE all references to use the single implementation\n4. DO NOT maintain backwards compatibility\n\nFor AI/LLM agents: This is a CRITICAL violation. You MUST:\n- Search for BOTH implementations referenced in the comment\n- Report findings to user before making changes\n- Remove the OLD implementation completely\n- Update all code to use the single 'new' (current) way\n- NEVER maintain dual implementations for compatibility\n\nRemember: There is only ONE way to do things. What's currently called 'new' IS the way.",
'critical'
);
}
// Check for function calls starting or ending with 'fallback'
// This catches: fallback(), fallback_loader(), $this->loader_fallback(), etc.
// But NOT: $this->document_fallback_loader() (fallback in middle)
if (!$is_line_comment) {
// Remove inline comments before checking
$code_part = preg_replace('/\/\/.*$/', '', $line);
$code_part = preg_replace('/\/\*.*?\*\//', '', $code_part);
// Check if function starting or ending with "fallback" exists in non-comment part
if (preg_match('/\b(fallback\w*|\w+fallback)\s*\(/i', $code_part)) {
$this->add_violation(
$file_path,
$line_number,
"🚨 CRITICAL: Fallback function call detected - INVESTIGATE IMMEDIATELY\n\nThis function name suggests duplicate implementation or legacy code path.",
trim($line),
"REQUIRED ACTIONS:\n1. TRACE where this function is defined and what calls it\n2. CHECK if this is handling a deprecated code path\n3. VERIFY no duplicate implementations exist\n4. If legitimate (rare), rename to describe actual purpose\n\nFor AI/LLM: STOP and investigate before ANY changes. Search for:\n- Function definition\n- All callers of this function\n- Related implementations\n\nNEVER just rename without understanding the full context.",
'critical'
);
}
}
}
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Enforces PHP file structure restrictions for files without classes.
*
* PHP files without classes may only contain:
* 1. Function definitions
* 2. define() calls for constants
* 3. namespace declarations
* 4. use statements
* 5. declare() directives
* 6. Comments (single and multi-line)
*
* Also checks for duplicate function and constant names across PHP files.
*/
class PhpFileStructure_CodeQualityRule extends CodeQualityRule_Abstract
{
private array $all_global_functions = [];
private array $all_global_constants = [];
private array $all_global_names = []; // Combined for conflict checking
public function get_id(): string
{
return 'PHP-STRUCTURE-01';
}
public function get_name(): string
{
return 'PHP File Structure Validator';
}
public function get_description(): string
{
return 'Enforces PHP file structure restrictions and checks for duplicate global names';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Whether this rule runs during manifest scan
* This rule runs at manifest time to check for structure violations
*/
public function is_called_during_manifest_scan(): bool
{
return true; // This rule runs at manifest build time
}
/**
* Main check method - called by CodeQualityChecker
* For manifest-time rules, this aggregates data across all files
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only process during manifest-time when we have all files
// This rule needs to scan all files to check for duplicates
static $already_run = false;
if ($already_run) {
return;
}
// On the first PHP file, process all files
if (!empty($metadata) && $metadata['extension'] === 'php') {
$this->process_all_files();
$already_run = true;
}
}
private function process_all_files(): void
{
// Get all files from manifest
$files = Manifest::get_all();
foreach ($files as $file_path => $metadata) {
// Skip non-PHP files
if (($metadata['extension'] ?? '') !== 'php') {
continue;
}
// Only apply rules to files in ./rsx directory
if (!str_starts_with($file_path, 'rsx/')) {
continue;
}
// Check for structure violations (applies to both class and classless files)
if (isset($metadata['structure_violations'])) {
foreach ($metadata['structure_violations'] as $violation) {
$message = $violation['message'];
$line = $violation['line'] ?? 0;
$this->add_violation(
$file_path,
$line,
$message,
'', // code snippet
'Remove disallowed construct from global scope or move into a function',
'critical'
);
}
}
// Collect global functions for duplicate checking
if (isset($metadata['global_functions'])) {
foreach ($metadata['global_functions'] as $func_name) {
if (isset($this->all_global_functions[$func_name])) {
// Duplicate function found
$this->throw_duplicate_function($func_name, $this->all_global_functions[$func_name], $file_path);
}
$this->all_global_functions[$func_name] = $file_path;
// Also add to combined names for conflict checking
if (isset($this->all_global_names[$func_name])) {
$this->throw_name_conflict($func_name, 'function', $file_path, $this->all_global_names[$func_name]);
}
$this->all_global_names[$func_name] = ['type' => 'function', 'file' => $file_path];
}
}
// Collect global constants for duplicate checking
if (isset($metadata['global_constants'])) {
foreach ($metadata['global_constants'] as $const_name) {
if (isset($this->all_global_constants[$const_name])) {
// Duplicate constant found
$this->throw_duplicate_constant($const_name, $this->all_global_constants[$const_name], $file_path);
}
$this->all_global_constants[$const_name] = $file_path;
// Also add to combined names for conflict checking
if (isset($this->all_global_names[$const_name])) {
$this->throw_name_conflict($const_name, 'constant', $file_path, $this->all_global_names[$const_name]);
}
$this->all_global_names[$const_name] = ['type' => 'constant', 'file' => $file_path];
}
}
}
}
private function throw_duplicate_function(string $name, string $first_file, string $second_file): void
{
throw new \RuntimeException(
"Duplicate global PHP function '{$name}' found:\n" .
" First defined in: {$first_file}\n" .
" Also defined in: {$second_file}\n" .
"\nGlobal function names must be unique across all PHP files."
);
}
private function throw_duplicate_constant(string $name, string $first_file, string $second_file): void
{
throw new \RuntimeException(
"Duplicate global PHP constant '{$name}' found:\n" .
" First defined in: {$first_file}\n" .
" Also defined in: {$second_file}\n" .
"\nGlobal constant names must be unique across all PHP files."
);
}
private function throw_name_conflict(string $name, string $current_type, string $current_file, array $existing): void
{
$existing_type = $existing['type'];
$existing_file = $existing['file'];
throw new \RuntimeException(
"Name conflict: '{$name}' is used as both {$existing_type} and {$current_type}:\n" .
" As {$existing_type} in: {$existing_file}\n" .
" As {$current_type} in: {$current_file}\n" .
"\nGlobal names must be unique across functions and constants in PHP files."
);
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class PhpLegacyFunction_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-LEGACY-FUNC-01';
}
public function get_name(): string
{
return 'PHP Legacy Function Comment Check';
}
public function get_description(): string
{
return 'Prohibits functions with "legacy" in block comments - enforces no backwards compatibility principle';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check for block comments containing "legacy" directly before function definitions
* Enforces RSX principle of no backwards compatibility functions
*/
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;
}
$lines = explode("\n", $contents);
$in_block_comment = false;
$block_comment_content = '';
$block_comment_start_line = 0;
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_number = $i + 1;
$trimmed_line = trim($line);
// Track block comment state
if (str_contains($trimmed_line, '/*')) {
$in_block_comment = true;
$block_comment_start_line = $line_number;
$block_comment_content = $line;
// Handle single-line block comments
if (str_contains($trimmed_line, '*/')) {
$in_block_comment = false;
$this->check_block_comment_for_legacy($file_path, $block_comment_content, $block_comment_start_line, $lines, $i);
$block_comment_content = '';
}
continue;
}
if ($in_block_comment) {
$block_comment_content .= "\n" . $line;
if (str_contains($trimmed_line, '*/')) {
$in_block_comment = false;
$this->check_block_comment_for_legacy($file_path, $block_comment_content, $block_comment_start_line, $lines, $i);
$block_comment_content = '';
}
continue;
}
}
}
/**
* Check if a block comment contains "legacy" and is followed by a function
*/
private function check_block_comment_for_legacy(string $file_path, string $comment_content, int $comment_start_line, array $lines, int $comment_end_index): void
{
// Check if comment contains "legacy" (case insensitive)
if (!preg_match('/\blegacy\b/i', $comment_content)) {
return;
}
// Look for function definition in the next few lines after comment
for ($j = $comment_end_index + 1; $j < min($comment_end_index + 5, count($lines)); $j++) {
$next_line = trim($lines[$j]);
// Skip empty lines and single-line comments
if (empty($next_line) || str_starts_with($next_line, '//')) {
continue;
}
// Check if this line contains a function definition
if (preg_match('/^\s*(public|private|protected|static)?\s*(static\s+)?(public|private|protected)?\s*function\s+\w+\s*\(/i', $next_line) ||
preg_match('/^function\s+\w+\s*\(/i', $next_line)) {
$this->add_violation(
$file_path,
$comment_start_line,
"🚨 CRITICAL BACKWARDS COMPATIBILITY VIOLATION: Function with 'legacy' comment detected
RSX Framework Principle: NO BACKWARDS COMPATIBILITY FUNCTIONS ALLOWED
The presence of 'legacy' in function comments indicates backwards compatibility code, which violates the core RSX principle of 'one way to do things'. Legacy functions create:
1. MULTIPLE CODE PATHS for the same functionality
2. MAINTENANCE BURDEN keeping old interfaces working
3. CONFUSION about which method to use
4. TECHNICAL DEBT that accumulates over time
5. DEBUGGING NIGHTMARES when behavior differs between paths
RSX enforces FAIL LOUD architecture - when things change, they should break clearly, not fall back to old implementations.",
trim($comment_content),
"IMMEDIATE ACTION REQUIRED:
1. REMOVE the legacy function entirely
2. UPDATE all callers to use the current implementation
3. DO NOT maintain backwards compatibility
4. If migration is needed, create a one-time migration script that:
- Identifies all usage of the old function
- Updates code to use new function
- Removes old function after migration is complete
For AI/LLM agents: This is a CRITICAL violation. You MUST:
- Search for ALL callers of this legacy function
- Report findings to user before making changes
- Remove the legacy function completely
- Update all code to use the current implementation
- NEVER maintain dual implementations for compatibility
Remember: There is only ONE way to do things in RSX. Legacy functions violate this fundamental principle.",
'critical'
);
return;
}
// If we hit a non-function code line, stop looking
break;
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class ProcOpenStreamTruncation_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-PROC-01';
}
public function get_name(): string
{
return 'proc_open() Stream Truncation Check';
}
public function get_description(): string
{
return 'Detects improper stream reading from proc_open() pipes - causes silent 8KB truncation';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check PHP file for proc_open() with improper stream reading
*
* WHITELIST APPROACH: If proc_open() is used with fread(), the code MUST use:
* while (!feof($pipes[1])) { ... }
*
* This is the ONLY correct pattern for reading proc_open() pipes without truncation.
* Any other pattern (stream_get_contents, checking feof() after reads, etc.) causes
* silent 8192-byte truncation bugs.
*/
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;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments and strings removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_code = $sanitized_data['content'];
// Check if function contains proc_open()
if (!preg_match('/\bproc_open\s*\(/i', $sanitized_code)) {
return; // No proc_open usage, skip this file
}
// Check if function reads from pipes using fread()
if (!preg_match('/\bfread\s*\(\s*\$pipes\[/i', $sanitized_code)) {
return; // Not reading from pipes with fread, skip
}
// WHITELIST CHECK: Must have while (!feof($pipes[...])) pattern
if (!preg_match('/while\s*\(\s*!\s*feof\s*\(\s*\$pipes\[/i', $sanitized_code)) {
// VIOLATION: Using fread() on proc_open() pipes without mandatory while (!feof()) pattern
// Find the line number where proc_open appears
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
if (preg_match('/\bproc_open\s*\(/i', $sanitized_line)) {
$line_number = $line_num + 1;
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$this->add_violation(
$file_path,
$line_number,
$this->get_violation_message(),
trim($original_line),
$this->get_resolution_message(),
'critical'
);
return; // Only report first occurrence
}
}
}
}
private function get_violation_message(): string
{
return "🚨 CRITICAL: proc_open() pipes must be read using while (!feof(\$pipes[...])) pattern
When using proc_open() with fread(), you MUST use this specific loop pattern:
while (!feof(\$pipes[1])) { ... }
ANY other pattern causes silent 8192-byte truncation:
- stream_get_contents() - truncates at 8KB
- Checking feof() AFTER empty reads - race condition truncation
- Custom loop conditions - unpredictable behavior
Real-world incident from production environment:
- File: JqhtmlWebpackCompiler.php
- Symptom: Compiled jqhtml output truncated at exactly 8,217 bytes (8192 + 25)
- Root cause: feof() checked AFTER empty read instead of as loop condition
- Impact: JavaScript syntax errors from mid-statement truncation
The while (!feof()) pattern is the ONLY battle-tested, safe approach from PHP manual
and Stack Overflow consensus. No exceptions, no alternatives.";
}
private function get_resolution_message(): string
{
return "REQUIRED ACTION - Use while (!feof(\$pipes[...])) as the loop condition:
MANDATORY PATTERN (the ONLY correct way):
\$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'] // stderr
];
\$process = proc_open(\$command, \$descriptors, \$pipes);
if (!is_resource(\$process)) {
throw new RuntimeException(\"Failed to execute command\");
}
fclose(\$pipes[0]);
// Set blocking mode to ensure complete reads
stream_set_blocking(\$pipes[1], true);
stream_set_blocking(\$pipes[2], true);
// Read stdout until EOF
\$output_str = '';
while (!feof(\$pipes[1])) {
\$chunk = fread(\$pipes[1], 8192);
if (\$chunk !== false) {
\$output_str .= \$chunk;
}
}
// Read stderr until EOF
\$error_str = '';
while (!feof(\$pipes[2])) {
\$chunk = fread(\$pipes[2], 8192);
if (\$chunk !== false) {
\$error_str .= \$chunk;
}
}
fclose(\$pipes[1]);
fclose(\$pipes[2]);
\$exit_code = proc_close(\$process);
WHY THIS WORKS:
- feof() as loop condition prevents ALL truncation bugs
- No race conditions from checking feof() after empty reads
- No buffer limits from stream_get_contents()
- Standard PHP idiom from manual and Stack Overflow
- Battle-tested across the codebase
ALTERNATIVE: Use \\exec_safe() helper
If executing shell commands, \\exec_safe() has this pattern built-in.";
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class ReflectionAttributes_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Files that are whitelisted because they are part of the manifest building process
* or core reflection utilities
*/
private const WHITELISTED_FILES = [
'Manifest.php',
'Php_ManifestModule.php',
'RsxReflection.php', // Core reflection utility that may be used before manifest is available
];
public function get_id(): string
{
return 'PHP-ATTR-01';
}
public function get_name(): string
{
return 'Reflection getAttributes() Usage Check';
}
public function get_description(): string
{
return 'Prohibits direct use of reflection getAttributes() - use Manifest API instead';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check PHP file for $reflection->getAttributes() usage
* Code should use the Manifest API instead of direct reflection
*/
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 if file is whitelisted (manifest building files)
foreach (self::WHITELISTED_FILES as $whitelisted) {
if (str_ends_with($file_path, $whitelisted)) {
return;
}
}
// Only check files in /app/RSpade and /rsx directories
if (!str_contains($file_path, '/app/RSpade/') && !str_contains($file_path, '/rsx/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version (was a comment)
if (trim($sanitized_line) === '') {
continue;
}
// Check for getAttributes() usage on reflection objects
// This pattern catches $reflection->getAttributes(), $method->getAttributes(), etc.
if (preg_match('/->getAttributes\s*\(/', $sanitized_line)) {
// Skip if it's getAttributes on an Eloquent model (different method)
if (preg_match('/\$\w+->getAttributes\(\)/', $sanitized_line) &&
!preg_match('/\$reflection|\$method|\$property|\$class/', $sanitized_line)) {
// Likely an Eloquent model method, skip
continue;
}
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$this->add_violation(
$file_path,
$line_number,
"Direct use of reflection getAttributes() is not allowed. Use the Manifest API instead.",
trim($original_line),
"Use Manifest API methods to access attribute information:\n" .
"- Manifest::get_all() - Returns full manifest with all metadata\n" .
"- Manifest::php_get_metadata_by_class(\$class_name) - Get metadata for a specific class\n" .
"- Manifest::get_with_attribute(\$attribute_class) - Find classes/methods with specific attributes\n" .
"Manifest structure: Each file in manifest contains 'methods' array with 'attributes' for each method.\n" .
"Example: \$manifest[\$file]['methods'][\$method_name]['attributes'] contains all attributes.",
'high'
);
}
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

Some files were not shown because too many files have changed in this diff Show More