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>
444 lines
18 KiB
PHP
444 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\RSpade\Commands\Refactor;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use App\RSpade\Core\Manifest\Manifest;
|
|
use App\RSpade\Commands\Refactor\Php\MethodReferenceScanner;
|
|
use App\RSpade\Commands\Refactor\Php\MethodUpdater;
|
|
use App\RSpade\Upstream\RefactorLog;
|
|
use RuntimeException;
|
|
|
|
class RenamePhpClassFunction_Command extends Command
|
|
{
|
|
protected $signature = 'rsx:refactor:rename_php_class_function
|
|
{class_name : The class containing the method}
|
|
{old_method : Current static method name}
|
|
{new_method : New static method name}
|
|
{--force-rename-static-self : Only rename self::/static:: references in class}
|
|
{--skip-subclasses : Don\'t recursively rename in subclasses}
|
|
{--dry-run : Show what would be changed without modifying files}';
|
|
|
|
protected $description = 'Rename a static method across all RSX files including subclasses';
|
|
|
|
public function handle()
|
|
{
|
|
$class_name = $this->argument('class_name');
|
|
$old_method = $this->argument('old_method');
|
|
$new_method = $this->argument('new_method');
|
|
$force_rename_static_self = $this->option('force-rename-static-self');
|
|
$skip_subclasses = $this->option('skip-subclasses');
|
|
$dry_run = $this->option('dry-run');
|
|
|
|
// Initialize manifest
|
|
Manifest::init();
|
|
|
|
// Phase 1: Validation
|
|
$this->info("Validating method: {$class_name}::{$old_method}");
|
|
|
|
try {
|
|
$class_path = Manifest::php_find_class($class_name);
|
|
$class_metadata = Manifest::get_file($class_path);
|
|
} catch (RuntimeException $e) {
|
|
$this->error("Class '{$class_name}' not found in manifest");
|
|
return 1;
|
|
}
|
|
|
|
$absolute_class_path = base_path($class_path);
|
|
|
|
// Build FQCN from metadata
|
|
$class_fqcn = $class_metadata['namespace'] . '\\' . $class_metadata['class'];
|
|
|
|
// Check if method exists and is static
|
|
$method_info = $this->find_method_in_class($absolute_class_path, $old_method);
|
|
|
|
if ($method_info === null) {
|
|
if ($force_rename_static_self) {
|
|
$this->line("<fg=yellow>⚠</> Method '{$old_method}' not found in class (allowed with --force-rename-static-self)");
|
|
$method_was_renamed = false;
|
|
} else {
|
|
$this->error("Method '{$class_name}::{$old_method}' not found in class definition");
|
|
return 1;
|
|
}
|
|
} else {
|
|
if (!$method_info['is_static']) {
|
|
$this->error("Method '{$class_name}::{$old_method}' is not static. Only static methods can be renamed.");
|
|
return 1;
|
|
}
|
|
|
|
$this->info("<fg=green>✓</> Method found and is static");
|
|
|
|
// Check if destination method already exists
|
|
$new_method_info = $this->find_method_in_class($absolute_class_path, $new_method);
|
|
if ($new_method_info !== null) {
|
|
$this->error("Method '{$class_name}::{$new_method}' already exists. Cannot rename.");
|
|
return 1;
|
|
}
|
|
|
|
$this->info("<fg=green>✓</> Destination method available");
|
|
$method_was_renamed = true;
|
|
}
|
|
|
|
// Phase 1.5: Pre-flight check for framework file modifications
|
|
$this->preflight_check_framework_files($class_name, $class_fqcn, $old_method, $absolute_class_path, $method_was_renamed, $skip_subclasses);
|
|
|
|
if ($dry_run) {
|
|
$this->info("");
|
|
$this->info("<fg=yellow>DRY RUN - No files will be modified</>");
|
|
return $this->preview_changes($class_name, $class_fqcn, $old_method, $new_method, $absolute_class_path, $method_was_renamed, $skip_subclasses);
|
|
}
|
|
|
|
// Phase 2: Rename method definition (if it exists)
|
|
if ($method_was_renamed) {
|
|
$this->info("");
|
|
$this->info("Renaming method definition...");
|
|
|
|
$updater = new MethodUpdater();
|
|
$renamed = $updater->rename_method_definition($absolute_class_path, $old_method, $new_method);
|
|
|
|
if ($renamed) {
|
|
$this->info("<fg=green>✓</> Updated: " . $class_path);
|
|
} else {
|
|
$this->error("Failed to rename method definition");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Phase 3a: ALWAYS update static::/self:: references in class file
|
|
$this->info("");
|
|
$this->info("Updating static::/self:: references in class...");
|
|
|
|
$updater = $updater ?? new MethodUpdater();
|
|
$static_self_count = $updater->update_static_self_references($absolute_class_path, $old_method, $new_method);
|
|
|
|
if ($static_self_count > 0) {
|
|
$this->info("<fg=green>✓</> Updated {$static_self_count} static::/self:: reference(s) in class");
|
|
} else {
|
|
$this->line("<fg=gray>No static::/self:: references found in class</>");
|
|
}
|
|
|
|
// Phase 3b: Update Class::method references in all files (only if method was renamed)
|
|
if ($method_was_renamed) {
|
|
$this->info("");
|
|
$this->info("Scanning for references to '{$class_name}::{$old_method}'...");
|
|
|
|
$scanner = new MethodReferenceScanner();
|
|
$references = $scanner->find_all_method_references($class_name, $class_fqcn, $old_method);
|
|
|
|
if (!empty($references)) {
|
|
$this->info("Found " . count($references) . " file(s) with references:");
|
|
$this->info("");
|
|
|
|
foreach ($references as $file_path => $occurrences) {
|
|
$relative_path = str_replace(base_path() . '/', '', $file_path);
|
|
$this->line(" <fg=cyan>{$relative_path}</>");
|
|
$this->line(" <fg=gray>" . count($occurrences) . " occurrence(s)</>");
|
|
}
|
|
|
|
$this->info("");
|
|
$this->info("Updating references...");
|
|
|
|
$updated_count = $updater->update_method_references($references, $class_name, $class_fqcn, $old_method, $new_method);
|
|
$this->info("<fg=green>✓</> Updated {$updated_count} file(s)");
|
|
} else {
|
|
$this->line("<fg=gray>No external references found</>");
|
|
}
|
|
}
|
|
|
|
// Phase 3.5: If controller, update Route() references
|
|
if ($method_was_renamed) {
|
|
$is_controller = false;
|
|
try {
|
|
if (Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
|
|
$is_controller = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Class might not be loaded, skip controller check
|
|
}
|
|
|
|
if ($is_controller) {
|
|
$this->info("");
|
|
$this->info("Detected controller class - updating Route() action references...");
|
|
|
|
$updater = $updater ?? new MethodUpdater();
|
|
$route_updated_count = $updater->update_controller_action_route_references($class_name, $old_method, $new_method);
|
|
|
|
if ($route_updated_count > 0) {
|
|
$this->info("<fg=green>✓</> Updated Route() references in {$route_updated_count} file(s)");
|
|
} else {
|
|
$this->line("<fg=gray>No Route() action references found</>");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 4: Recursive subclass renaming (unless --skip-subclasses)
|
|
if (!$skip_subclasses) {
|
|
$this->info("");
|
|
$this->info("Processing subclasses...");
|
|
|
|
$subclasses = Manifest::php_get_subclasses_of($class_name, true);
|
|
|
|
if (!empty($subclasses)) {
|
|
$this->info("Found " . count($subclasses) . " subclass(es): " . implode(', ', $subclasses));
|
|
|
|
foreach ($subclasses as $subclass) {
|
|
$exit_code = Artisan::call('rsx:refactor:rename_php_class_function', [
|
|
'class_name' => $subclass,
|
|
'old_method' => $old_method,
|
|
'new_method' => $new_method,
|
|
'--force-rename-static-self' => true,
|
|
'--skip-subclasses' => true,
|
|
]);
|
|
|
|
if ($exit_code === 0) {
|
|
$this->line(" <fg=green>✓</> Updated {$subclass}");
|
|
} else {
|
|
$this->line(" <fg=yellow>⚠</> Skipped {$subclass} (no references)");
|
|
}
|
|
}
|
|
} else {
|
|
$this->line("<fg=gray>No subclasses found</>");
|
|
}
|
|
}
|
|
|
|
// Log refactor operation for upstream tracking
|
|
RefactorLog::log_refactor(
|
|
"rsx:refactor:rename_php_class_function {$class_name} {$old_method} {$new_method}",
|
|
$class_path
|
|
);
|
|
|
|
// Phase 5: Final grep for any remaining references
|
|
$this->info("");
|
|
$this->info("Checking for remaining references to '{$class_name}' and '{$old_method}' on same line...");
|
|
|
|
$remaining_files = $this->grep_remaining_method_references($class_name, $old_method);
|
|
|
|
if (!empty($remaining_files)) {
|
|
$this->warn("");
|
|
$this->warn("Found {$class_name} and {$old_method} on same line in " . count($remaining_files) . " file(s) (manual review needed):");
|
|
foreach ($remaining_files as $file) {
|
|
$this->warn(" - {$file}");
|
|
}
|
|
} else {
|
|
$this->info("<fg=green>✓</> No remaining references found");
|
|
}
|
|
|
|
// Success summary
|
|
$this->info("");
|
|
$this->info("<fg=green>✓</> Successfully renamed {$class_name}::{$old_method} → {$class_name}::{$new_method}");
|
|
$this->info("");
|
|
$this->info("Next steps:");
|
|
$this->info(" 1. Review changes: git diff");
|
|
$this->info(" 2. Test application");
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Grep for files where both class name and old method name appear on the same line
|
|
*
|
|
* @param string $class_name Class name to search for
|
|
* @param string $old_method Old method name to search for
|
|
* @return array List of relative file paths where both appear on same line
|
|
*/
|
|
protected function grep_remaining_method_references(string $class_name, string $old_method): array
|
|
{
|
|
$rsx_path = base_path('rsx');
|
|
|
|
if (!is_dir($rsx_path)) {
|
|
return [];
|
|
}
|
|
|
|
// Use grep to find files containing both class name and method name on same line
|
|
// We'll grep for the class name, then check each result for method name on same line
|
|
$command = sprintf(
|
|
'grep -r -l -w %s --include="*.php" --include="*.blade.php" --include="*.js" --include="*.jqhtml" %s 2>/dev/null',
|
|
escapeshellarg($class_name),
|
|
escapeshellarg($rsx_path)
|
|
);
|
|
|
|
$output = shell_exec($command);
|
|
|
|
if (empty($output)) {
|
|
return [];
|
|
}
|
|
|
|
$candidate_files = explode("\n", trim($output));
|
|
$matching_files = [];
|
|
|
|
// For each file that contains the class name, check if any line has both class and method
|
|
foreach ($candidate_files as $file) {
|
|
if (empty($file)) {
|
|
continue;
|
|
}
|
|
|
|
// Read file and check each line
|
|
$content = file_get_contents($file);
|
|
$lines = explode("\n", $content);
|
|
|
|
foreach ($lines as $line) {
|
|
// Check if line contains both class name and method name as whole words
|
|
if (preg_match('/\b' . preg_quote($class_name, '/') . '\b/', $line) &&
|
|
preg_match('/\b' . preg_quote($old_method, '/') . '\b/', $line)) {
|
|
$matching_files[] = str_replace(base_path() . '/', '', $file);
|
|
break; // Found in this file, no need to check more lines
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_unique($matching_files);
|
|
}
|
|
|
|
/**
|
|
* Find a method in a class file and return metadata
|
|
*
|
|
* @return array|null ['is_static' => bool, 'line' => int] or null if not found
|
|
*/
|
|
protected function find_method_in_class(string $file_path, string $method_name): ?array
|
|
{
|
|
$content = file_get_contents($file_path);
|
|
$tokens = token_get_all($content);
|
|
|
|
for ($i = 0; $i < count($tokens); $i++) {
|
|
if (!is_array($tokens[$i])) {
|
|
continue;
|
|
}
|
|
|
|
if ($tokens[$i][0] === T_FUNCTION) {
|
|
// Find method name
|
|
$method_found = false;
|
|
for ($j = $i + 1; $j < min(count($tokens), $i + 10); $j++) {
|
|
if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING && $tokens[$j][1] === $method_name) {
|
|
$method_found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($method_found) {
|
|
// Check if static by looking backwards
|
|
$is_static = false;
|
|
for ($k = $i - 1; $k >= max(0, $i - 20); $k--) {
|
|
if (!is_array($tokens[$k])) continue;
|
|
if ($tokens[$k][0] === T_STATIC) {
|
|
$is_static = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'is_static' => $is_static,
|
|
'line' => $tokens[$i][2]
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pre-flight check: Ensure no framework files would be modified when not in framework developer mode
|
|
*/
|
|
protected function preflight_check_framework_files(string $class_name, string $class_fqcn, string $old_method, string $class_path, bool $method_was_renamed, bool $skip_subclasses): void
|
|
{
|
|
// Skip check if in framework developer mode
|
|
if (config('rsx.code_quality.is_framework_developer', false)) {
|
|
return;
|
|
}
|
|
|
|
$framework_files = [];
|
|
|
|
// Check if class file itself is a framework file
|
|
$relative_class_path = str_replace(base_path() . '/', '', $class_path);
|
|
if (str_starts_with($relative_class_path, 'app/RSpade/')) {
|
|
$framework_files[] = $relative_class_path;
|
|
}
|
|
|
|
// Check for method references in other files (if method exists)
|
|
if ($method_was_renamed) {
|
|
$scanner = new MethodReferenceScanner();
|
|
$references = $scanner->find_all_method_references($class_name, $class_fqcn, $old_method);
|
|
|
|
foreach ($references as $file_path => $occurrences) {
|
|
$relative_path = str_replace(base_path() . '/', '', $file_path);
|
|
if (str_starts_with($relative_path, 'app/RSpade/')) {
|
|
$framework_files[] = $relative_path;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check subclasses (if not skipped)
|
|
if (!$skip_subclasses) {
|
|
$subclasses = Manifest::php_get_subclasses_of($class_name, true);
|
|
foreach ($subclasses as $subclass) {
|
|
try {
|
|
$subclass_path = Manifest::php_find_class($subclass);
|
|
if (str_starts_with($subclass_path, 'app/RSpade/')) {
|
|
$framework_files[] = $subclass_path;
|
|
}
|
|
} catch (RuntimeException $e) {
|
|
// Subclass not found in manifest - skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deduplicate
|
|
$framework_files = array_unique($framework_files);
|
|
|
|
// Throw fatal error if framework files would be modified
|
|
if (!empty($framework_files)) {
|
|
$this->error("");
|
|
$this->error("FATAL: This refactor would modify " . count($framework_files) . " framework file(s) in app/RSpade/");
|
|
$this->error("");
|
|
$this->error("Framework files that would be modified:");
|
|
foreach ($framework_files as $file) {
|
|
$this->error(" - {$file}");
|
|
}
|
|
$this->error("");
|
|
$this->error("Refactoring core framework files is only available to RSpade framework maintainers.");
|
|
$this->error("Set IS_FRAMEWORK_DEVELOPER=true in .env if you are maintaining the framework.");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preview changes without modifying files
|
|
*/
|
|
protected function preview_changes(string $class_name, string $class_fqcn, string $old_method, string $new_method, string $class_path, bool $method_was_renamed, bool $skip_subclasses): int
|
|
{
|
|
$this->info("");
|
|
|
|
if ($method_was_renamed) {
|
|
$this->line("Would rename method definition in:");
|
|
$this->line(" " . str_replace(base_path() . '/', '', $class_path));
|
|
$this->info("");
|
|
|
|
$scanner = new MethodReferenceScanner();
|
|
$references = $scanner->find_all_method_references($class_name, $class_fqcn, $old_method);
|
|
|
|
if (!empty($references)) {
|
|
$this->line("Would update " . count($references) . " file(s) with {$class_name}::{$old_method} references");
|
|
}
|
|
}
|
|
|
|
$static_self_refs = (new MethodReferenceScanner())->find_static_self_references($class_path, $old_method);
|
|
if (!empty($static_self_refs)) {
|
|
$this->line("Would update " . count($static_self_refs) . " static::/self:: reference(s) in class");
|
|
}
|
|
|
|
if (!$skip_subclasses) {
|
|
$subclasses = Manifest::php_get_subclasses_of($class_name, true);
|
|
if (!empty($subclasses)) {
|
|
$this->info("");
|
|
$this->line("Would recursively update " . count($subclasses) . " subclass(es):");
|
|
foreach ($subclasses as $subclass) {
|
|
$this->line(" - {$subclass}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
}
|