Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
<?php
namespace App\RSpade\Commands\Refactor\Php;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\PHP\Php_Parser;
use RuntimeException;
/**
* Scans PHP and Blade files for class references
*/
class ClassReferenceScanner
{
/**
* Find all references to a class in ./rsx and ./app/RSpade PHP and Blade files
*
* @param string $class_name Simple class name to find
* @param string $source_fqcn Fully qualified class name from manifest
* @return array Map of file paths to array of occurrences
*/
public function find_all_references(string $class_name, string $source_fqcn): array
{
$references = [];
// Get all files from manifest (automatically excludes resource/, public/, etc.)
$all_files = Manifest::get_all();
foreach ($all_files as $relative_path => $file_meta) {
$extension = $file_meta['extension'] ?? '';
$absolute_path = base_path($relative_path);
// Only process PHP and Blade files in rsx/ or app/RSpade/
if (!str_starts_with($relative_path, 'rsx/') && !str_starts_with($relative_path, 'app/RSpade/')) {
continue;
}
// Process PHP files (not blade)
if ($extension === 'php') {
$occurrences = $this->find_in_php_file($absolute_path, $class_name);
if (!empty($occurrences)) {
$references[$absolute_path] = $occurrences;
}
}
// Process Blade files
elseif ($extension === 'blade.php') {
$occurrences = $this->find_in_blade_file($absolute_path, $class_name);
if (!empty($occurrences)) {
$references[$absolute_path] = $occurrences;
}
}
}
return $references;
}
/**
* Find occurrences of a class name in a PHP file using token analysis
*
* @param string $file_path Absolute path to PHP file
* @param string $class_name Simple class name to find
* @return array Array of occurrence details with line numbers
*/
protected function find_in_php_file(string $file_path, string $class_name): array
{
$content = file_get_contents($file_path);
$tokens = token_get_all($content);
$occurrences = [];
for ($i = 0; $i < count($tokens); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$token = $tokens[$i];
$token_type = $token[0];
$token_value = $token[1];
$line = $token[2];
// Check if this is a class name reference
if ($token_type === T_STRING && $token_value === $class_name) {
// Determine context
$context = $this->determine_context($tokens, $i);
$occurrences[] = [
'line' => $line,
'context' => $context,
'token_value' => $token_value
];
}
}
return $occurrences;
}
/**
* Determine the context of a class name token
*/
protected function determine_context(array $tokens, int $index): string
{
// Look backwards for context (increased to 15 for namespaced use statements)
for ($i = $index - 1; $i >= max(0, $index - 15); $i--) {
if (!is_array($tokens[$i])) {
if ($tokens[$i] === '(') {
return 'function_call_or_instantiation';
}
continue;
}
$prev_token = $tokens[$i][0];
if ($prev_token === T_NEW) {
return 'new';
}
if ($prev_token === T_EXTENDS) {
return 'extends';
}
if ($prev_token === T_IMPLEMENTS) {
return 'implements';
}
if ($prev_token === T_INSTANCEOF) {
return 'instanceof';
}
if ($prev_token === T_USE) {
return 'use_statement';
}
if ($prev_token === T_CLASS) {
return 'class_declaration';
}
if ($prev_token === T_DOUBLE_COLON) {
return 'static_access';
}
// Skip whitespace and namespace separators
if (in_array($prev_token, [T_WHITESPACE, T_NS_SEPARATOR, T_STRING])) {
continue;
}
// Hit something else, stop
break;
}
// Look forwards for context
for ($i = $index + 1; $i < min(count($tokens), $index + 5); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$next_token = $tokens[$i][0];
if ($next_token === T_DOUBLE_COLON) {
return 'static_access';
}
}
return 'unknown';
}
/**
* Find occurrences of a class name in a Blade file
*
* @param string $file_path Absolute path to Blade file
* @param string $class_name Simple class name to find
* @return array Array of occurrence details
*/
protected function find_in_blade_file(string $file_path, string $class_name): array
{
$content = file_get_contents($file_path);
$occurrences = [];
// Extract PHP code from Blade directives
$php_segments = $this->extract_php_from_blade($content);
foreach ($php_segments as $segment) {
$segment_occurrences = $this->find_in_php_code($segment['code'], $class_name);
foreach ($segment_occurrences as $occurrence) {
$occurrences[] = [
'line' => $segment['line'] + $occurrence['line'] - 1,
'context' => $occurrence['context'],
'blade_directive' => $segment['type']
];
}
}
return $occurrences;
}
/**
* Extract PHP code segments from Blade content
*/
protected function extract_php_from_blade(string $content): array
{
$segments = [];
$lines = explode("\n", $content);
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_num = $i + 1;
// Match {{ }} and {!! !!} expressions
if (preg_match_all('/\{\{(.+?)\}\}|\{!!(.+?)!!\}/s', $line, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $match) {
$code = trim($match[0], '{}! ');
$segments[] = [
'code' => "<?php {$code} ?>",
'line' => $line_num,
'type' => 'echo'
];
}
}
// Match @directive() calls
if (preg_match_all('/@(\w+)\s*\((.+?)\)/s', $line, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$directive = $match[1];
$args = $match[2];
// Skip Blade comments
if ($directive === 'rsx_id' || $directive === 'section' || $directive === 'yield') {
continue;
}
$segments[] = [
'code' => "<?php {$args} ?>",
'line' => $line_num,
'type' => "@{$directive}"
];
}
}
// Match @php...@endphp blocks
if (preg_match('/@php/', $line)) {
$php_block = '';
$start_line = $line_num;
// Collect lines until @endphp
for ($j = $i; $j < count($lines); $j++) {
$php_block .= $lines[$j] . "\n";
if (preg_match('/@endphp/', $lines[$j])) {
break;
}
}
// Extract PHP code between @php and @endphp
$php_code = preg_replace('/@php(.+?)@endphp/s', '$1', $php_block);
$segments[] = [
'code' => "<?php {$php_code} ?>",
'line' => $start_line,
'type' => '@php_block'
];
$i = $j; // Skip past the block
}
}
return $segments;
}
/**
* Find class references in a PHP code string
*/
protected function find_in_php_code(string $php_code, string $class_name): array
{
$tokens = @token_get_all($php_code);
if ($tokens === false) {
return [];
}
$occurrences = [];
for ($i = 0; $i < count($tokens); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$token = $tokens[$i];
$token_type = $token[0];
$token_value = $token[1];
$line = $token[2] ?? 1;
if ($token_type === T_STRING && $token_value === $class_name) {
$context = $this->determine_context($tokens, $i);
$occurrences[] = [
'line' => $line,
'context' => $context
];
}
}
return $occurrences;
}
}

View File

@@ -0,0 +1,340 @@
<?php
namespace App\RSpade\Commands\Refactor\Php;
use RuntimeException;
/**
* Updates class references in PHP and Blade files with atomic writes
*/
class FileUpdater
{
/**
* Update class references in all affected files
*
* @param array $references Map of file paths to occurrences
* @param string $old_class Old class name
* @param string $new_class New class name
* @param string $source_fqcn Source class FQCN from manifest
* @return int Number of files updated
*/
public function update_class_references(array $references, string $old_class, string $new_class, string $source_fqcn): int
{
$updated_count = 0;
foreach ($references as $file_path => $occurrences) {
if ($this->update_file($file_path, $old_class, $new_class, $source_fqcn)) {
$updated_count++;
}
}
return $updated_count;
}
/**
* Update a single file with class name replacements
*
* @param string $file_path Absolute path to file
* @param string $old_class Old class name
* @param string $new_class New class name
* @param string $source_fqcn Source class FQCN from manifest
* @return bool True if file was updated
*/
protected function update_file(string $file_path, string $old_class, string $new_class, string $source_fqcn): bool
{
$content = file_get_contents($file_path);
if ($content === false) {
throw new RuntimeException("Failed to read file: {$file_path}");
}
// Replace class name with context awareness
$updated_content = $this->replace_class_name($content, $old_class, $new_class, $source_fqcn);
// Check if any changes were made
if ($updated_content === $content) {
return false;
}
// Write atomically using temp file
$temp_file = $file_path . '.refactor-temp';
if (file_put_contents($temp_file, $updated_content) === false) {
throw new RuntimeException("Failed to write temp file: {$temp_file}");
}
if (!rename($temp_file, $file_path)) {
@unlink($temp_file);
throw new RuntimeException("Failed to replace file: {$file_path}");
}
return true;
}
/**
* Replace class name in content with context awareness
*
* Uses token-based replacement to avoid false positives in strings/comments
*/
protected function replace_class_name(string $content, string $old_class, string $new_class, string $source_fqcn): string
{
// For Blade files, we need special handling
if (str_contains($content, '@') || str_contains($content, '{{')) {
return $this->replace_in_blade($content, $old_class, $new_class, $source_fqcn);
}
// For pure PHP files, use token-based replacement
return $this->replace_in_php($content, $old_class, $new_class, $source_fqcn);
}
/**
* Replace class name in PHP content using token analysis
*/
protected function replace_in_php(string $content, string $old_class, string $new_class, string $source_fqcn): string
{
$tokens = token_get_all($content);
$output = '';
// Calculate new FQCN for string literal replacement
$source_fqcn_normalized = ltrim($source_fqcn, '\\');
$new_fqcn = preg_replace('/' . preg_quote($old_class, '/') . '$/', $new_class, $source_fqcn_normalized);
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
// String tokens are passed through as-is
if (!is_array($token)) {
$output .= $token;
continue;
}
$token_type = $token[0];
$token_value = $token[1];
// Replace FQCN in string literals
if ($token_type === T_CONSTANT_ENCAPSED_STRING) {
$quote = $token_value[0];
$string_content = substr($token_value, 1, -1);
// Check if string exactly equals source FQCN (with or without leading \)
if ($string_content === $source_fqcn_normalized || $string_content === '\\' . $source_fqcn_normalized) {
$leading_slash = str_starts_with($string_content, '\\') ? '\\' : '';
$output .= $quote . $leading_slash . $new_fqcn . $quote;
continue;
}
}
// Check if this is start of a FQCN
if ($token_type === T_STRING || $token_type === T_NS_SEPARATOR) {
$fqcn_result = $this->check_and_replace_fqcn($tokens, $i, $old_class, $new_class);
if ($fqcn_result !== null) {
// We found and replaced a FQCN, output the replacement and skip ahead
$output .= $fqcn_result['replacement'];
$i = $fqcn_result['end_index'];
continue;
}
}
// Replace simple T_STRING tokens that match the old class name
if ($token_type === T_STRING && $token_value === $old_class) {
// Verify this is actually a class reference (not in string/comment)
if ($this->is_class_reference_context($tokens, $i)) {
$output .= $new_class;
} else {
$output .= $token_value;
}
} else {
$output .= $token_value;
}
}
return $output;
}
/**
* Check if we're at the start of a FQCN and if it should be replaced
*
* @return array|null Returns ['replacement' => string, 'end_index' => int] or null
*/
protected function check_and_replace_fqcn(array $tokens, int $start_index, string $old_class, string $new_class): ?array
{
// Build the full FQCN from consecutive T_STRING and T_NS_SEPARATOR tokens
$fqcn_parts = [];
$i = $start_index;
while ($i < count($tokens)) {
if (!is_array($tokens[$i])) {
break;
}
$token_type = $tokens[$i][0];
if ($token_type === T_STRING) {
$fqcn_parts[] = $tokens[$i][1];
$i++;
} elseif ($token_type === T_NS_SEPARATOR) {
$fqcn_parts[] = '\\';
$i++;
} elseif ($token_type === T_WHITESPACE) {
// Skip whitespace
$i++;
} else {
break;
}
}
// Need at least 2 parts for a FQCN (namespace + class)
if (count($fqcn_parts) < 3) {
return null;
}
$fqcn = implode('', $fqcn_parts);
// Extract class name (last part after final \)
$class_name = basename(str_replace('\\', '/', $fqcn));
// Check if class name matches
if ($class_name !== $old_class) {
return null;
}
// Check if FQCN starts with Rsx\ or App\RSpade\
$normalized_fqcn = ltrim($fqcn, '\\');
if (!str_starts_with($normalized_fqcn, 'Rsx\\') && !str_starts_with($normalized_fqcn, 'App\\RSpade\\')) {
return null;
}
// Build replacement FQCN with new class name
$namespace = dirname(str_replace('\\', '/', $fqcn));
$namespace = str_replace('/', '\\', $namespace);
if ($namespace === '.') {
$replacement = $new_class;
} else {
// Preserve leading \ if original had it
$leading_slash = str_starts_with($fqcn, '\\') ? '\\' : '';
$replacement = $leading_slash . $namespace . '\\' . $new_class;
}
return [
'replacement' => $replacement,
'end_index' => $i - 1
];
}
/**
* Check if a token is in a valid class reference context
*/
protected function is_class_reference_context(array $tokens, int $index): bool
{
// Look backwards for context clues
for ($i = $index - 1; $i >= max(0, $index - 10); $i--) {
if (!is_array($tokens[$i])) {
// Skip non-token characters like ( ) , etc
continue;
}
$prev_token = $tokens[$i][0];
// Valid contexts
if (in_array($prev_token, [T_CLASS, T_NEW, T_EXTENDS, T_IMPLEMENTS, T_INSTANCEOF, T_USE, T_DOUBLE_COLON])) {
return true;
}
// Skip whitespace, namespace separators, and comments
if (in_array($prev_token, [T_WHITESPACE, T_NS_SEPARATOR, T_COMMENT, T_DOC_COMMENT])) {
continue;
}
// For type hints, check if we're after ( or ,
if ($prev_token === T_STRING) {
// Could be part of a namespace
continue;
}
// If we hit something else meaningful, stop looking
if (!in_array($prev_token, [T_WHITESPACE, T_NS_SEPARATOR, T_STRING])) {
break;
}
}
// Look forwards for static access
for ($i = $index + 1; $i < min(count($tokens), $index + 5); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$next_token = $tokens[$i][0];
if ($next_token === T_DOUBLE_COLON) {
return true;
}
if (in_array($next_token, [T_WHITESPACE, T_NS_SEPARATOR])) {
continue;
}
break;
}
// Check if this appears to be a type hint (before a variable)
for ($i = $index + 1; $i < min(count($tokens), $index + 5); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$next_token = $tokens[$i][0];
// Type hint if followed by a variable
if ($next_token === T_VARIABLE) {
return true;
}
if ($next_token === T_WHITESPACE) {
continue;
}
break;
}
return false;
}
/**
* Replace class name in Blade content
*
* Uses simple regex replacement since Blade mixes PHP and HTML
*/
protected function replace_in_blade(string $content, string $old_class, string $new_class, string $source_fqcn): string
{
// Pattern matches class name as a word boundary (not part of another identifier)
// This prevents replacing "User" in "UserController" or in the middle of strings
$pattern = '/\b' . preg_quote($old_class, '/') . '\b/';
// Replace with word boundary check to avoid partial matches
$updated = preg_replace_callback($pattern, function ($matches) use ($old_class, $new_class, $content) {
// Additional safety: check if this looks like it's in a string literal
// This is a simple heuristic - if surrounded by quotes, skip it
$pos = strpos($content, $matches[0]);
if ($pos !== false) {
// Check 50 chars before and after for quote context
$before = substr($content, max(0, $pos - 50), 50);
$after = substr($content, $pos, 50);
// Count quotes before and after
$quotes_before = substr_count($before, '"') + substr_count($before, "'");
$quotes_after = substr_count($after, '"') + substr_count($after, "'");
// If odd number of quotes before/after, likely inside a string
if ($quotes_before % 2 === 1 || $quotes_after % 2 === 1) {
return $matches[0]; // Keep original
}
}
return $new_class;
}, $content);
return $updated;
}
}

View File

@@ -0,0 +1,358 @@
<?php
namespace App\RSpade\Commands\Refactor\Php;
use App\RSpade\Core\Manifest\Manifest;
use RuntimeException;
/**
* Scans PHP and Blade files for static method references
*/
class MethodReferenceScanner
{
/**
* Find all references to a static method in ./rsx and ./app/RSpade PHP and Blade files
*
* @param string $class_name Simple class name
* @param string $class_fqcn Fully qualified class name
* @param string $method_name Method name to find
* @return array Map of file paths to array of occurrences
*/
public function find_all_method_references(string $class_name, string $class_fqcn, string $method_name): array
{
$references = [];
// Get all files from manifest
$all_files = Manifest::get_all();
foreach ($all_files as $relative_path => $file_meta) {
$extension = $file_meta['extension'] ?? '';
$absolute_path = base_path($relative_path);
// Only process PHP and Blade files in rsx/ or app/RSpade/
if (!str_starts_with($relative_path, 'rsx/') && !str_starts_with($relative_path, 'app/RSpade/')) {
continue;
}
// Process PHP files
if ($extension === 'php') {
$occurrences = $this->find_in_php_file($absolute_path, $class_name, $class_fqcn, $method_name);
if (!empty($occurrences)) {
$references[$absolute_path] = $occurrences;
}
}
// Process Blade files
elseif ($extension === 'blade.php') {
$occurrences = $this->find_in_blade_file($absolute_path, $class_name, $class_fqcn, $method_name);
if (!empty($occurrences)) {
$references[$absolute_path] = $occurrences;
}
}
}
return $references;
}
/**
* Find static::/self:: references to a method within a specific file
*
* @param string $file_path Absolute path to PHP file
* @param string $method_name Method name to find
* @return array Array of occurrence details
*/
public function find_static_self_references(string $file_path, string $method_name): array
{
$content = file_get_contents($file_path);
$tokens = token_get_all($content);
$occurrences = [];
for ($i = 0; $i < count($tokens); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$token = $tokens[$i];
$token_type = $token[0];
$token_value = $token[1];
$line = $token[2];
// Look for static:: or self::
$is_static_or_self = ($token_type === T_STATIC) ||
($token_type === T_STRING && $token_value === 'self');
if (!$is_static_or_self) {
continue;
}
// Check for :: followed by method name
$next_non_whitespace = $this->get_next_non_whitespace_token($tokens, $i);
if ($next_non_whitespace && $next_non_whitespace['token'] === '::') {
$method_token = $this->get_next_non_whitespace_token($tokens, $next_non_whitespace['index']);
if ($method_token &&
is_array($method_token['token']) &&
$method_token['token'][0] === T_STRING &&
$method_token['token'][1] === $method_name) {
$occurrences[] = [
'line' => $line,
'context' => $token_value === 'self' ? 'self::' : 'static::',
'method' => $method_name
];
}
}
}
return $occurrences;
}
/**
* Find method references in a PHP file using token analysis
*/
protected function find_in_php_file(string $file_path, string $class_name, string $class_fqcn, string $method_name): array
{
$content = file_get_contents($file_path);
$tokens = token_get_all($content);
$occurrences = [];
for ($i = 0; $i < count($tokens); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$token = $tokens[$i];
$token_type = $token[0];
$token_value = $token[1];
$line = $token[2];
// Look for simple class name (T_STRING) or FQCN (T_NAME_FULLY_QUALIFIED) followed by ::
$is_match = false;
if ($token_type === T_STRING && $token_value === $class_name) {
$is_match = true;
} elseif (defined('T_NAME_FULLY_QUALIFIED') && $token_type === T_NAME_FULLY_QUALIFIED) {
// Strip leading backslash for comparison
$normalized_token = ltrim($token_value, '\\');
$normalized_fqcn = ltrim($class_fqcn, '\\');
if ($normalized_token === $normalized_fqcn) {
$is_match = true;
}
}
if ($is_match) {
$next_non_whitespace = $this->get_next_non_whitespace_token($tokens, $i);
// Check for :: - can be either T_DOUBLE_COLON token or string '::'
$is_double_colon = false;
if ($next_non_whitespace) {
$next_token = $next_non_whitespace['token'];
if ($next_token === '::') {
$is_double_colon = true;
} elseif (is_array($next_token) && $next_token[0] === T_DOUBLE_COLON) {
$is_double_colon = true;
}
}
if ($is_double_colon) {
$method_token = $this->get_next_non_whitespace_token($tokens, $next_non_whitespace['index']);
if ($method_token &&
is_array($method_token['token']) &&
$method_token['token'][0] === T_STRING &&
$method_token['token'][1] === $method_name) {
$occurrences[] = [
'line' => $line,
'context' => 'static_call',
'class' => $class_name,
'method' => $method_name
];
}
}
}
}
return $occurrences;
}
/**
* Find method references in a Blade file
*/
protected function find_in_blade_file(string $file_path, string $class_name, string $class_fqcn, string $method_name): array
{
$content = file_get_contents($file_path);
$occurrences = [];
// Extract PHP code from Blade directives
$php_segments = $this->extract_php_from_blade($content);
foreach ($php_segments as $segment) {
$segment_occurrences = $this->find_in_php_code($segment['code'], $class_name, $class_fqcn, $method_name);
foreach ($segment_occurrences as $occurrence) {
$occurrences[] = [
'line' => $segment['line'] + $occurrence['line'] - 1,
'context' => $occurrence['context'],
'blade_directive' => $segment['type'],
'class' => $class_name,
'method' => $method_name
];
}
}
return $occurrences;
}
/**
* Extract PHP code segments from Blade content
*/
protected function extract_php_from_blade(string $content): array
{
$segments = [];
$lines = explode("\n", $content);
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_num = $i + 1;
// Match {{ }} and {!! !!} expressions
if (preg_match_all('/\{\{(.+?)\}\}|\{!!(.+?)!!\}/s', $line, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $match) {
$code = trim($match[0], '{}! ');
$segments[] = [
'code' => "<?php {$code} ?>",
'line' => $line_num,
'type' => 'echo'
];
}
}
// Match @directive() calls
if (preg_match_all('/@(\w+)\s*\((.+?)\)/s', $line, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$directive = $match[1];
$args = $match[2];
$segments[] = [
'code' => "<?php {$args} ?>",
'line' => $line_num,
'type' => "@{$directive}"
];
}
}
// Match @php...@endphp blocks
if (preg_match('/@php/', $line)) {
$php_block = '';
$start_line = $line_num;
for ($j = $i; $j < count($lines); $j++) {
$php_block .= $lines[$j] . "\n";
if (preg_match('/@endphp/', $lines[$j])) {
break;
}
}
$php_code = preg_replace('/@php(.+?)@endphp/s', '$1', $php_block);
$segments[] = [
'code' => "<?php {$php_code} ?>",
'line' => $start_line,
'type' => '@php_block'
];
$i = $j;
}
}
return $segments;
}
/**
* Find method references in a PHP code string
*/
protected function find_in_php_code(string $php_code, string $class_name, string $class_fqcn, string $method_name): array
{
$tokens = @token_get_all($php_code);
if ($tokens === false) {
return [];
}
$occurrences = [];
for ($i = 0; $i < count($tokens); $i++) {
if (!is_array($tokens[$i])) {
continue;
}
$token = $tokens[$i];
$token_type = $token[0];
$token_value = $token[1];
$line = $token[2] ?? 1;
// Look for simple class name (T_STRING) or FQCN (T_NAME_FULLY_QUALIFIED) followed by ::
$is_match = false;
if ($token_type === T_STRING && $token_value === $class_name) {
$is_match = true;
} elseif (defined('T_NAME_FULLY_QUALIFIED') && $token_type === T_NAME_FULLY_QUALIFIED) {
// Strip leading backslash for comparison
$normalized_token = ltrim($token_value, '\\');
$normalized_fqcn = ltrim($class_fqcn, '\\');
if ($normalized_token === $normalized_fqcn) {
$is_match = true;
}
}
if ($is_match) {
$next_non_whitespace = $this->get_next_non_whitespace_token($tokens, $i);
// Check for :: - can be either T_DOUBLE_COLON token or string '::'
$is_double_colon = false;
if ($next_non_whitespace) {
$next_token = $next_non_whitespace['token'];
if ($next_token === '::') {
$is_double_colon = true;
} elseif (is_array($next_token) && $next_token[0] === T_DOUBLE_COLON) {
$is_double_colon = true;
}
}
if ($is_double_colon) {
$method_token = $this->get_next_non_whitespace_token($tokens, $next_non_whitespace['index']);
if ($method_token &&
is_array($method_token['token']) &&
$method_token['token'][0] === T_STRING &&
$method_token['token'][1] === $method_name) {
$occurrences[] = [
'line' => $line,
'context' => 'static_call'
];
}
}
}
}
return $occurrences;
}
/**
* Get next non-whitespace token
*/
protected function get_next_non_whitespace_token(array $tokens, int $start_index): ?array
{
for ($i = $start_index + 1; $i < count($tokens); $i++) {
$token = $tokens[$i];
// Skip whitespace tokens
if (is_array($token) && $token[0] === T_WHITESPACE) {
continue;
}
return ['token' => $token, 'index' => $i];
}
return null;
}
}

View File

@@ -0,0 +1,359 @@
<?php
namespace App\RSpade\Commands\Refactor\Php;
use RuntimeException;
/**
* Updates method references in PHP and Blade files
*/
class MethodUpdater
{
/**
* Rename a method definition in a class file
*
* @param string $file_path Absolute path to class file
* @param string $old_method Old method name
* @param string $new_method New method name
* @return bool True if method was found and renamed
*/
public function rename_method_definition(string $file_path, string $old_method, string $new_method): bool
{
$content = file_get_contents($file_path);
$tokens = token_get_all($content);
$output = '';
$method_found = false;
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
if (!is_array($token)) {
$output .= $token;
continue;
}
$token_type = $token[0];
$token_value = $token[1];
// Look for function declarations
if ($token_type === T_FUNCTION) {
// Find the method name (next T_STRING token)
$method_name_index = null;
for ($j = $i + 1; $j < min(count($tokens), $i + 10); $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
$method_name_index = $j;
break;
}
}
if ($method_name_index && $tokens[$method_name_index][1] === $old_method) {
// Verify this is a static method 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;
}
}
if ($is_static) {
$method_found = true;
// Output everything up to but not including the method name
$output .= $token_value;
// Skip to method name and replace it
for ($j = $i + 1; $j < $method_name_index; $j++) {
$output .= is_array($tokens[$j]) ? $tokens[$j][1] : $tokens[$j];
}
// Output new method name
$output .= $new_method;
// Skip past the old method name
$i = $method_name_index;
continue;
}
}
}
$output .= $token_value;
}
if ($method_found) {
$this->write_file_atomically($file_path, $output);
}
return $method_found;
}
/**
* Update static::/self:: references in a class file
*
* @param string $file_path Absolute path to class file
* @param string $old_method Old method name
* @param string $new_method New method name
* @return int Number of replacements made
*/
public function update_static_self_references(string $file_path, string $old_method, string $new_method): int
{
$content = file_get_contents($file_path);
$tokens = token_get_all($content);
$output = '';
$replacement_count = 0;
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
if (!is_array($token)) {
$output .= $token;
continue;
}
$token_type = $token[0];
$token_value = $token[1];
// Look for static:: or self::
$is_static_or_self = ($token_type === T_STATIC) ||
($token_type === T_STRING && $token_value === 'self');
if ($is_static_or_self) {
// Check for :: followed by method name
$double_colon_index = null;
$method_name_index = null;
// Find :: - can be either T_DOUBLE_COLON token or string '::'
for ($j = $i + 1; $j < min(count($tokens), $i + 5); $j++) {
$is_double_colon_token = false;
if ($tokens[$j] === '::') {
$is_double_colon_token = true;
} elseif (is_array($tokens[$j]) && $tokens[$j][0] === T_DOUBLE_COLON) {
$is_double_colon_token = true;
}
if ($is_double_colon_token) {
$double_colon_index = $j;
break;
}
if (is_array($tokens[$j]) && $tokens[$j][0] !== T_WHITESPACE) {
break;
}
}
if ($double_colon_index) {
// Find method name after ::
for ($j = $double_colon_index + 1; $j < min(count($tokens), $double_colon_index + 5); $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
if ($tokens[$j][1] === $old_method) {
$method_name_index = $j;
}
break;
}
if (is_array($tokens[$j]) && $tokens[$j][0] !== T_WHITESPACE) {
break;
}
}
}
if ($method_name_index) {
// Output static/self
$output .= $token_value;
// Output everything between static/self and method name (whitespace, ::)
for ($j = $i + 1; $j < $method_name_index; $j++) {
$output .= is_array($tokens[$j]) ? $tokens[$j][1] : $tokens[$j];
}
// Output new method name
$output .= $new_method;
// Skip to after method name
$i = $method_name_index;
$replacement_count++;
continue;
}
}
$output .= $token_value;
}
if ($replacement_count > 0) {
$this->write_file_atomically($file_path, $output);
}
return $replacement_count;
}
/**
* Update Class::method references across all files
*
* @param array $references Map of file paths to occurrences from MethodReferenceScanner
* @param string $class_name Class name
* @param string $class_fqcn Fully qualified class name
* @param string $old_method Old method name
* @param string $new_method New method name
* @return int Number of files updated
*/
public function update_method_references(array $references, string $class_name, string $class_fqcn, string $old_method, string $new_method): int
{
$updated_count = 0;
foreach ($references as $file_path => $occurrences) {
if ($this->update_file_method_references($file_path, $class_name, $class_fqcn, $old_method, $new_method)) {
$updated_count++;
}
}
return $updated_count;
}
/**
* Update method references in a single file
*/
protected function update_file_method_references(string $file_path, string $class_name, string $class_fqcn, string $old_method, string $new_method): bool
{
$content = file_get_contents($file_path);
// Determine if this is a Blade file
if (str_ends_with($file_path, '.blade.php')) {
$updated_content = $this->replace_method_in_blade($content, $class_name, $class_fqcn, $old_method, $new_method);
} else {
$updated_content = $this->replace_method_in_php($content, $class_name, $class_fqcn, $old_method, $new_method);
}
// Check if any changes were made
if ($updated_content === $content) {
return false;
}
$this->write_file_atomically($file_path, $updated_content);
return true;
}
/**
* Replace method references in PHP content
*/
protected function replace_method_in_php(string $content, string $class_name, string $class_fqcn, string $old_method, string $new_method): string
{
$tokens = token_get_all($content);
$output = '';
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
if (!is_array($token)) {
$output .= $token;
continue;
}
$token_type = $token[0];
$token_value = $token[1];
// Look for simple class name (T_STRING) or FQCN (T_NAME_FULLY_QUALIFIED) followed by :: and method name
$is_match = false;
if ($token_type === T_STRING && $token_value === $class_name) {
$is_match = true;
} elseif (defined('T_NAME_FULLY_QUALIFIED') && $token_type === T_NAME_FULLY_QUALIFIED) {
// Strip leading backslash for comparison
$normalized_token = ltrim($token_value, '\\');
$normalized_fqcn = ltrim($class_fqcn, '\\');
if ($normalized_token === $normalized_fqcn) {
$is_match = true;
}
}
if ($is_match) {
$double_colon_index = null;
$method_name_index = null;
// Find :: - can be either T_DOUBLE_COLON token or string '::'
for ($j = $i + 1; $j < min(count($tokens), $i + 5); $j++) {
$is_double_colon_token = false;
if ($tokens[$j] === '::') {
$is_double_colon_token = true;
} elseif (is_array($tokens[$j]) && $tokens[$j][0] === T_DOUBLE_COLON) {
$is_double_colon_token = true;
}
if ($is_double_colon_token) {
$double_colon_index = $j;
break;
}
if (is_array($tokens[$j]) && $tokens[$j][0] !== T_WHITESPACE) {
break;
}
}
if ($double_colon_index) {
// Find method name after ::
for ($j = $double_colon_index + 1; $j < min(count($tokens), $double_colon_index + 5); $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
if ($tokens[$j][1] === $old_method) {
$method_name_index = $j;
}
break;
}
if (is_array($tokens[$j]) && $tokens[$j][0] !== T_WHITESPACE) {
break;
}
}
}
if ($method_name_index) {
// Output class name (simple or FQCN)
$output .= $token_value;
// Output everything between class and method name (whitespace, ::)
for ($j = $i + 1; $j < $method_name_index; $j++) {
$output .= is_array($tokens[$j]) ? $tokens[$j][1] : $tokens[$j];
}
// Output new method name
$output .= $new_method;
// Skip to after method name
$i = $method_name_index;
continue;
}
}
$output .= $token_value;
}
return $output;
}
/**
* Replace method references in Blade content
*/
protected function replace_method_in_blade(string $content, string $class_name, string $class_fqcn, string $old_method, string $new_method): string
{
// Pattern to match simple Class::method with word boundaries
$pattern1 = '/\b' . preg_quote($class_name, '/') . '\s*::\s*' . preg_quote($old_method, '/') . '\b/';
$content = preg_replace($pattern1, $class_name . '::' . $new_method, $content);
// Pattern to match FQCN \Namespace\Class::method
$escaped_fqcn = preg_quote($class_fqcn, '/');
$pattern2 = '/\\\\?' . $escaped_fqcn . '\s*::\s*' . preg_quote($old_method, '/') . '\b/';
$content = preg_replace($pattern2, '\\' . $class_fqcn . '::' . $new_method, $content);
return $content;
}
/**
* Write file atomically using temp file
*/
protected function write_file_atomically(string $file_path, string $content): void
{
$temp_file = $file_path . '.refactor-temp';
if (file_put_contents($temp_file, $content) === false) {
throw new RuntimeException("Failed to write temp file: {$temp_file}");
}
if (!rename($temp_file, $file_path)) {
@unlink($temp_file);
throw new RuntimeException("Failed to replace file: {$file_path}");
}
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\RSpade\Commands\Refactor;
use Illuminate\Console\Command;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Commands\Refactor\Php\ClassReferenceScanner;
use App\RSpade\Commands\Refactor\Php\FileUpdater;
use App\RSpade\Core\PHP\Filename_Suggester;
use App\RSpade\Upstream\RefactorLog;
class RefactorPhpClass_Command extends Command
{
protected $signature = 'rsx:refactor:rename_php_class
{old_class : The current class name to rename}
{new_class : The new class name}
{--dry-run : Show what would be changed without modifying files}
{--skip-rename-file : Skip renaming the source file after class refactor}';
protected $description = 'Refactor a PHP class name across all RSX files (PHP and Blade)';
public function handle()
{
$old_class = $this->argument('old_class');
$new_class = $this->argument('new_class');
$dry_run = $this->option('dry-run');
// Initialize manifest
Manifest::init();
// Step 1: Validate source class exists in manifest
$this->info("Validating source class: {$old_class}");
try {
$source_path = Manifest::php_find_class($old_class);
$source_metadata = Manifest::get_file($source_path);
} catch (\RuntimeException $e) {
$this->error("Source class '{$old_class}' not found in manifest");
return 1;
}
$source_fqcn = $source_metadata['namespace'] . '\\' . $source_metadata['class'];
// Step 2: Validate source class is in allowed directories
if (!str_starts_with($source_path, 'rsx/') && !str_starts_with($source_path, 'app/RSpade/')) {
$this->error("Source class must be in ./rsx or ./app/RSpade directories");
$this->error("Found in: {$source_path}");
return 1;
}
$this->info("Source class found: {$source_path}");
// Step 3: Validate destination class does NOT exist in manifest
$this->info("Validating destination class: {$new_class}");
try {
$conflicting_path = Manifest::php_find_class($new_class);
$this->error("Destination class '{$new_class}' already exists in manifest");
$this->error("Conflicting file: {$conflicting_path}");
return 1;
} catch (\RuntimeException $e) {
// Good - destination class doesn't exist
$this->info("Destination class available");
}
// Step 4: Scan all PHP and Blade files for references
$this->info("");
$this->info("Scanning for references to '{$old_class}'...");
$scanner = new ClassReferenceScanner();
$references = $scanner->find_all_references($old_class, $source_fqcn);
if (empty($references)) {
$this->warn("No references found to '{$old_class}'");
return 0;
}
// Step 5: Check for framework file modifications when not in framework developer mode
if (!config('rsx.code_quality.is_framework_developer', false)) {
$framework_files = [];
foreach ($references as $file_path => $occurrences) {
if (str_starts_with($file_path, 'app/RSpade/')) {
$framework_files[] = $file_path;
}
}
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.");
return 1;
}
}
// Step 6: Display changes preview
$this->info("");
$this->info("Found " . count($references) . " file(s) with references:");
$this->info("");
foreach ($references as $file_path => $occurrences) {
$this->line(" <fg=cyan>{$file_path}</>");
$this->line(" <fg=gray>" . count($occurrences) . " occurrence(s)</>");
}
if ($dry_run) {
$this->info("");
$this->info("<fg=yellow>DRY RUN - No files modified</>");
return 0;
}
// Step 7: Apply changes
$this->info("");
$this->info("Applying changes...");
$updater = new FileUpdater();
$updated_count = $updater->update_class_references($references, $old_class, $new_class, $source_fqcn);
$this->info("");
$this->info("<fg=green>Successfully updated {$updated_count} file(s)</>");
// Step 8: Log refactor operation for upstream tracking
RefactorLog::log_refactor(
"rsx:refactor:rename_php_class {$old_class} {$new_class}",
$source_path
);
// Step 9: Auto-rename the source file if needed
if (!$this->option('skip-rename-file')) {
$this->info("");
$this->info("Checking if source file needs renaming...");
$this->rename_source_file_if_needed($source_path, $new_class, $source_metadata);
}
$this->info("");
$this->info("Next steps:");
$this->info(" 1. Review changes: git diff");
$this->info(" 2. Test application");
return 0;
}
/**
* Rename the source file to match the new class name if needed
*
* @param string $source_path Current relative file path
* @param string $new_class New class name
* @param array $metadata File metadata from manifest
*/
protected function rename_source_file_if_needed(string $source_path, string $new_class, array $metadata): void
{
$extension = $metadata['extension'] ?? 'php';
$is_rspade = str_starts_with($source_path, 'app/RSpade/');
// Get suggested filename
$suggested_filename = Filename_Suggester::get_suggested_class_filename(
$source_path,
$new_class,
$extension,
$is_rspade,
false // Not a Jqhtml component
);
$current_filename = basename($source_path);
// Check if rename is needed
if ($current_filename === $suggested_filename) {
$this->info("<fg=green>✓</> Source filename already correct: {$current_filename}");
return;
}
// Build new path
$dir_path = dirname($source_path);
$new_path = $dir_path . '/' . $suggested_filename;
// Check if destination already exists
if (file_exists(base_path($new_path))) {
$this->warn("<fg=yellow>⚠</> Auto-rename skipped: Destination file already exists: {$new_path}");
return;
}
// Perform rename
$old_absolute = base_path($source_path);
$new_absolute = base_path($new_path);
rename($old_absolute, $new_absolute);
$this->info("<fg=green>✓</> Renamed: {$current_filename}{$suggested_filename}");
}
}

View File

@@ -0,0 +1,346 @@
<?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 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
);
// 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;
}
/**
* 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;
}
}

View File

@@ -0,0 +1,419 @@
<?php
namespace App\RSpade\Commands\Refactor;
use Illuminate\Console\Command;
use RuntimeException;
/**
* Reorganizes methods in PHP class files according to RSpade conventions
*
* This command reorders methods in PHP class files following a standardized structure:
*
* ORDERING LOGIC:
* 1. Public static methods (no underscore)
* 2. Public non-static methods (no underscore)
* 3. [Conditional - only app/RSpade/ files] Public methods with single underscore
* - Prefixed with "RSpade Public Internal Methods" section header
* 4. Public methods with 2+ underscores
* 5. [Conditional - if any exist] Protected/Private methods section
* - Prefixed with "Protected / Private Methods" section header
* - Ordered by: static (no underscore), non-static (no underscore),
* static (single underscore), non-static (single underscore),
* remaining static, remaining non-static
*
* SECTION HEADERS:
* - "RSpade Public Internal Methods" section only appears for app/RSpade/ files
* containing public methods with single underscore prefix
* - "Protected / Private Methods" section only appears if protected/private methods exist
* - Existing section headers are removed and conditionally re-added
*
* PRESERVATION:
* - Class properties, constants, and non-method comments remain in place
* - Method ordering within groups preserved unless --sort-alpha specified
* - Attributes, PHPDoc, and inline comments stay with their methods
*
* PROCESSING:
* 1. Validates file is in manifest and contains a PHP class
* 2. Extracts all methods with full headers (attributes, PHPDoc, comments)
* 3. Removes methods from file
* 4. Runs PHP formatter on stripped file
* 5. Finds last closing brace (class end)
* 6. Re-inserts methods in specified order with section headers
* 7. Runs PHP formatter again
* 8. Writes file atomically
*
* FLAGS:
* --sort-alpha: Alphabetically sort methods within each group
*/
class SortFunctions_Command extends Command
{
protected $signature = 'rsx:refactor:sort_php_class_functions
{file : Path to PHP class file (relative to base_path)}
{--sort-alpha : Sort methods alphabetically within groups}';
protected $description = 'Reorganize methods in PHP class files according to RSpade conventions';
public function handle()
{
$file_path = $this->argument('file');
$sort_alpha = $this->option('sort-alpha');
// Normalize path
$file_path = str_replace('\\', '/', $file_path);
$file_path = ltrim($file_path, '/');
$absolute_path = base_path($file_path);
if (!file_exists($absolute_path)) {
$this->error("File does not exist: {$absolute_path}");
return 1;
}
$contents = file_get_contents($absolute_path);
// Get lines and trim them all
$lines = file($absolute_path);
foreach ($lines as $index => $line) {
$lines[$index] = trim($line);
}
// Find last closing brace (class close)
$class_close_index = null;
for ($i = count($lines) - 1; $i >= 0; $i--) {
if ($lines[$i] === '}') {
$class_close_index = $i;
break;
}
}
if ($class_close_index === null) {
throw new RuntimeException("Could not find class closing brace");
}
// Remove existing section headers from previous sorts
$section_headers = [
[
'// ----------------------------------',
'// RSpade Public Internal Methods:',
'// ----------------------------------',
],
[
'// ----------------------------------',
'// Protected / Private Methods:',
'// ----------------------------------',
],
[
'// ------------------------------------------------------------------------',
'// ---- RSpade Public Internal Methods:',
'// ------------------------------------------------------------------------',
],
[
'// ------------------------------------------------------------------------',
'// ---- Private / Protected Methods:',
'// ------------------------------------------------------------------------',
],
];
for ($i = 0; $i < count($lines) - 2; $i++) {
foreach ($section_headers as $header) {
if ($lines[$i] === $header[0] &&
$lines[$i + 1] === $header[1] &&
$lines[$i + 2] === $header[2]) {
$lines[$i] = '';
$lines[$i + 1] = '';
$lines[$i + 2] = '';
break;
}
}
}
// Parse tokens to find functions with metadata
$tokens = token_get_all($contents);
$methods = [];
for ($i = 0; $i < count($tokens); $i++) {
if (is_array($tokens[$i]) && $tokens[$i][0] === T_FUNCTION) {
$line_num = $tokens[$i][2];
$definition_index = $line_num - 1; // Convert to 0-based
// Get visibility - scan backwards until newline (all on same line)
$visibility = 'public'; // Default
$is_static = false;
for ($j = $i - 1; $j >= 0; $j--) {
// Stop at newline - everything before this is a different line
if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE && strpos($tokens[$j][1], "\n") !== false) {
break;
}
if (!is_array($tokens[$j])) continue;
if ($tokens[$j][0] === T_PUBLIC) $visibility = 'public';
elseif ($tokens[$j][0] === T_PROTECTED) $visibility = 'protected';
elseif ($tokens[$j][0] === T_PRIVATE) $visibility = 'private';
elseif ($tokens[$j][0] === T_STATIC) $is_static = true;
}
// Get function name
$func_name = null;
for ($j = $i + 1; $j < count($tokens); $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
$func_name = $tokens[$j][1];
break;
}
if ($tokens[$j] === '(') break;
}
if ($func_name) {
$methods[$func_name] = [
'name' => $func_name,
'visibility' => $visibility,
'is_static' => $is_static,
'definition_index' => $definition_index,
];
}
}
}
if (empty($methods)) {
$this->warn("No methods found in file");
return 0;
}
// Find starting_index for each method
foreach ($methods as $name => $method) {
$def_index = $method['definition_index'];
// Go backwards from definition to find starting point
$found_closing_brace = false;
for ($i = $def_index - 1; $i >= 0; $i--) {
if ($lines[$i] === '}') {
// Found closing brace - start after it
$methods[$name]['starting_index'] = $i + 1;
$found_closing_brace = true;
break;
} elseif ($lines[$i] === '{') {
// Found opening brace - need to find empty line
break;
}
}
if (!$found_closing_brace) {
// Go backwards from definition to find first empty line
for ($i = $def_index - 1; $i >= 0; $i--) {
if ($lines[$i] === '') {
$methods[$name]['starting_index'] = $i + 1;
break;
}
}
}
// Safety check
if (!isset($methods[$name]['starting_index'])) {
$methods[$name]['starting_index'] = 0;
}
}
// Find method with lowest starting_index and recalculate it
$lowest_start = PHP_INT_MAX;
$lowest_method = null;
foreach ($methods as $name => $method) {
if ($method['starting_index'] < $lowest_start) {
$lowest_start = $method['starting_index'];
$lowest_method = $name;
}
}
if ($lowest_method !== null) {
$def_index = $methods[$lowest_method]['definition_index'];
for ($i = $def_index - 1; $i >= 0; $i--) {
if ($lines[$i] === '') {
$methods[$lowest_method]['starting_index'] = $i + 1;
break;
}
}
}
// Calculate ending_index for each method
foreach ($methods as $name => $method) {
$current_start = $method['starting_index'];
// Find next closest method starting_index
$next_start = null;
foreach ($methods as $other_name => $other_method) {
if ($other_name === $name) continue;
$other_start = $other_method['starting_index'];
if ($other_start > $current_start) {
if ($next_start === null || $other_start < $next_start) {
$next_start = $other_start;
}
}
}
// Set ending_index
if ($next_start !== null && $next_start <= $class_close_index) {
$methods[$name]['ending_index'] = $next_start - 1;
} else {
$methods[$name]['ending_index'] = $class_close_index - 1;
}
}
// Extract body for each method
foreach ($methods as $name => $method) {
$body_lines = [];
for ($i = $method['starting_index']; $i <= $method['ending_index']; $i++) {
$body_lines[] = $lines[$i];
}
$methods[$name]['body'] = trim(implode("\n", $body_lines));
}
// Blank out all method lines in $lines
foreach ($methods as $name => $method) {
for ($i = $method['starting_index']; $i <= $method['ending_index']; $i++) {
$lines[$i] = '';
}
}
// Sort methods alphabetically if requested
if ($sort_alpha) {
ksort($methods);
}
// Build sorted methods string following RSpade conventions
$sorted_methods_output = '';
$is_rspade = str_starts_with($file_path, 'app/RSpade/');
$remaining_methods = $methods;
// Group 1: Public static (no underscore)
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] === 'public' && $method['is_static'] && substr($name, 0, 1) !== '_') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 2: Public non-static (no underscore)
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] === 'public' && !$method['is_static'] && substr($name, 0, 1) !== '_') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 3: RSpade Internal Methods (single underscore public methods in app/RSpade/ only)
if ($is_rspade) {
$has_single_underscore_public = false;
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] === 'public' && substr($name, 0, 1) === '_' && substr($name, 1, 1) !== '_') {
$has_single_underscore_public = true;
break;
}
}
if ($has_single_underscore_public) {
$sorted_methods_output .= "// ------------------------------------------------------------------------\n";
$sorted_methods_output .= "// ---- RSpade Public Internal Methods:\n";
$sorted_methods_output .= "// ------------------------------------------------------------------------\n\n";
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] === 'public' && substr($name, 0, 1) === '_' && substr($name, 1, 1) !== '_') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
}
}
// Group 4: Public methods with 2+ underscores
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] === 'public' && substr($name, 0, 2) === '__') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 5+: Protected/Private Methods - Add header if any exist
$has_protected_private = !empty(array_filter($remaining_methods, fn($m) => $m['visibility'] !== 'public'));
if ($has_protected_private) {
$sorted_methods_output .= "// ------------------------------------------------------------------------\n";
$sorted_methods_output .= "// ---- Private / Protected Methods:\n";
$sorted_methods_output .= "// ------------------------------------------------------------------------\n\n";
// Group 6: Protected/private static (no underscore)
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] !== 'public' && $method['is_static'] && substr($name, 0, 1) !== '_') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 7: Protected/private non-static (no underscore)
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] !== 'public' && !$method['is_static'] && substr($name, 0, 1) !== '_') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 8: Protected/private static (single underscore)
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] !== 'public' && $method['is_static'] && substr($name, 0, 1) === '_' && substr($name, 1, 1) !== '_') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 9: Protected/private non-static (single underscore)
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] !== 'public' && !$method['is_static'] && substr($name, 0, 1) === '_' && substr($name, 1, 1) !== '_') {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 10: Remaining protected/private static
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] !== 'public' && $method['is_static']) {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
// Group 11: Remaining protected/private non-static
foreach ($remaining_methods as $name => $method) {
if ($method['visibility'] !== 'public' && !$method['is_static']) {
$sorted_methods_output .= $method['body'] . "\n\n";
unset($remaining_methods[$name]);
}
}
}
// Insert sorted methods before class closing brace
$insert_position = $class_close_index;
array_splice($lines, $insert_position, 0, $sorted_methods_output);
// Rebuild file
$final_content = implode("\n", $lines);
// Save file
file_put_contents($absolute_path, $final_content);
// Run PHP CS Fixer
$formatter_path = base_path('bin/formatters/php-formatter');
$command = 'php ' . escapeshellarg($formatter_path) . ' ' . escapeshellarg($absolute_path) . ' 2>&1';
\exec_safe($command, $output, $return_code);
if ($return_code !== 0) {
$this->error("PHP formatter failed:");
$this->line(implode("\n", $output));
return 1;
}
$this->info("✓ Methods sorted: " . count($methods) . ($sort_alpha ? ' (alphabetically)' : ''));
return 0;
}
}