Files
rspade_system/app/RSpade/CodeQuality/Rules/Common/RouteExists_CodeQualityRule.php
root 29c657f7a7 Exclude tests directory from framework publish
Add 100+ automated unit tests from .expect file specifications
Add session system test
Add rsx:constants:regenerate command test
Add rsx:logrotate command test
Add rsx:clean command test
Add rsx:manifest:stats command test
Add model enum system test
Add model mass assignment prevention test
Add rsx:check command test
Add migrate:status command test

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-25 03:59:58 +00:00

283 lines
11 KiB
PHP

<?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
{
// First check if this is a SPA action (JavaScript class extending Spa_Action)
if ($this->is_spa_action($controller)) {
return true;
}
// Otherwise check PHP routes
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 if a controller name is actually a SPA action class
*/
private function is_spa_action(string $class_name): bool
{
try {
// Use manifest to check if this JavaScript class extends Spa_Action
return \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Spa_Action');
} catch (\Exception $e) {
// If manifest not available, class doesn't exist, or any error, return false
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;
}
// Get original file content for extracting actual controller/method names
// (The $contents parameter may be sanitized with strings replaced by spaces)
$original_contents = file_get_contents($file_path);
// 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)) {
// Also match against original content to get real controller/method names
preg_match_all($pattern_two_params, $original_contents, $original_matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
// Extract controller and method from ORIGINAL content, not sanitized
$controller = $original_matches[1][$index][0] ?? $matches[1][$index][0];
$method = $original_matches[2][$index][0] ?? $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 - use ORIGINAL file content, not sanitized
$original_lines = explode("\n", $original_contents);
$code_snippet = isset($original_lines[$line_number - 1]) ? trim($original_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)) {
// Also match against original content to get real controller names
preg_match_all($pattern_one_param, $original_contents, $original_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
}
// Extract controller from ORIGINAL content, not sanitized
$controller_string = $original_matches[1][$index][0] ?? $matches[1][$index][0];
// Check if using Controller::method syntax
if (str_contains($controller_string, '::')) {
[$controller, $method] = explode('::', $controller_string, 2);
} else {
$controller = $controller_string;
$method = 'index'; // Default to 'index'
}
// 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 - use ORIGINAL file content, not sanitized
$original_lines = explode("\n", $original_contents);
$code_snippet = isset($original_lines[$line_number - 1]) ? trim($original_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);
}
}