Refactor filename naming system and apply convention-based renames

Standardize settings file naming and relocate documentation files
Fix code quality violations from rsx:check
Reorganize user_management directory into logical subdirectories
Move Quill Bundle to core and align with Tom Select pattern
Simplify Site Settings page to focus on core site information
Complete Phase 5: Multi-tenant authentication with login flow and site selection
Add route query parameter rule and synchronize filename validation logic
Fix critical bug in UpdateNpmCommand causing missing JavaScript stubs
Implement filename convention rule and resolve VS Code auto-rename conflict
Implement js-sanitizer RPC server to eliminate 900+ Node.js process spawns
Implement RPC server architecture for JavaScript parsing
WIP: Add RPC server infrastructure for JS parsing (partial implementation)
Update jqhtml terminology from destroy to stop, fix datagrid DOM preservation
Add JQHTML-CLASS-01 rule and fix redundant class names
Improve code quality rules and resolve violations
Remove legacy fatal error format in favor of unified 'fatal' error type
Filter internal keys from window.rsxapp output
Update button styling and comprehensive form/modal documentation
Add conditional fly-in animation for modals
Fix non-deterministic bundle compilation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-13 19:10:02 +00:00
parent fc494c1e08
commit 77b4d10af8
28155 changed files with 2191860 additions and 12967 deletions

View File

@@ -5,12 +5,64 @@ namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* FilenameRedundantPrefix_CodeQualityRule - Detects unnecessarily long filenames
* FilenameRedundantPrefix_CodeQualityRule - Enforces optimal filename clarity
*
* Suggests using short filenames when the directory structure already contains the prefix.
* This rule implements two complementary filename conventions:
*
* 1. SUBTRACTIVE CONVENTION: Remove Directory-Redundant Prefixes
* --------------------------------------------------------
* Groups files by common prefix and removes parts that duplicate the directory path.
*
* Rules:
* - Files are grouped by their longest common prefix (e.g., "frontend_calendar_event")
* - If the prefix contains parts that exist in the directory path, suggest removing them
* - ALL files in a group must be renamed together to maintain visual grouping
* - Single-segment filenames are NOT allowed (e.g., "calendar.blade.php" is invalid)
* - If ANY file would become single-segment, entire group is excluded
* - Must preserve immediate directory context for semantic clarity
*
* Example:
* Directory: rsx/app/frontend/calendar/
* frontend_calendar_event.blade.php calendar_event.blade.php
* frontend_calendar_event.js calendar_event.js
* frontend_calendar_event.scss calendar_event.scss
* frontend_calendar_event_controller.php calendar_event_controller.php
*
* All four files flagged together because they share "frontend_calendar_event" prefix,
* and "frontend" is redundant (exists in directory path).
*
* 2. ADDITIVE CONVENTION: Add Detail to Generic Filenames
* --------------------------------------------------
* Flags single-segment filenames that match their directory name.
*
* Rules:
* - File has exactly 1 segment (e.g., "edit")
* - Filename matches directory name
* - Suggests adding parent directory for context
*
* Example:
* Directory: rsx/app/frontend/clients/edit/
* edit.blade.php clients_edit.blade.php
*
* CRITICAL: File Prefix Grouping
* ------------------------------
* Files with the same prefix are conceptually grouped and developers rely on this
* visual relationship to identify related files. Breaking this grouping creates
* cognitive overhead and makes codebases harder to navigate.
*
* Implementation Details:
* - Checks run per-directory, analyzing all files together
* - Groups identified by longest common prefix before file-specific suffix
* - One violation generated per group (not per file)
* - Group consistency enforced: all files renamed together or not at all
*/
class FilenameRedundantPrefix_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Track which directories we've already processed to avoid duplicate violations
*/
private array $processed_directories = [];
public function get_id(): string
{
return 'CONV-FILENAME-01';
@@ -18,12 +70,12 @@ class FilenameRedundantPrefix_CodeQualityRule extends CodeQualityRule_Abstract
public function get_name(): string
{
return 'Filename Redundant Prefix Convention';
return 'Filename Clarity Convention';
}
public function get_description(): string
{
return 'Suggests using short filenames when directory structure contains the prefix';
return 'Suggests optimal filename clarity by adding or removing directory context';
}
public function get_file_patterns(): array
@@ -49,211 +101,618 @@ class FilenameRedundantPrefix_CodeQualityRule extends CodeQualityRule_Abstract
$extension = $metadata['extension'] ?? '';
$filename = basename($file_path);
$dir_path = dirname($relative_path);
$dir_key = $dir_path; // Check per directory, not per extension
// Check PHP/JS files with classes
// Get the identifier name (class name, @rsx_id, or Define: name)
$identifier = $this->extract_identifier($metadata, $extension);
if ($identifier === null) {
return; // No identifier to check
}
// Check additive case (too generic) - per file
$this->check_additive_case($relative_path, $filename, $identifier, $extension, $dir_path, $is_rspade);
// Check subtractive case (redundant prefix) - per directory across ALL extensions
if (!isset($this->processed_directories[$dir_key])) {
$this->check_subtractive_groups_all_extensions($dir_path, $is_rspade);
$this->processed_directories[$dir_key] = true;
}
}
/**
* Extract identifier (class name, @rsx_id, or Define: name) from metadata
*/
private function extract_identifier(array $metadata, string $extension): ?string
{
// PHP/JS files: use class name
if (isset($metadata['class'])) {
$this->check_class_redundancy($relative_path, $metadata['class'], $extension, $filename, $is_rspade);
return $metadata['class'];
}
// 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);
// Blade/jqhtml files: use id
if (($extension === 'blade.php' || $extension === 'jqhtml') && isset($metadata['id'])) {
return $metadata['id'];
}
// Check jqhtml files with Define:
if ($extension === 'jqhtml' && isset($metadata['id'])) {
$this->check_jqhtml_redundancy($relative_path, $metadata['id'], $filename, $is_rspade);
}
return null;
}
private function check_class_redundancy(string $file, string $class_name, string $extension, string $filename, bool $is_rspade): void
/**
* ADDITIVE: Check if filename is too generic (1 part matching directory name)
* Suggests adding parent directory detail
*/
private function check_additive_case(string $file, string $filename, string $identifier, string $extension, string $dir_path, 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);
$filename_base = $this->get_filename_base($filename, $extension);
$identifier_parts = explode('_', $identifier);
if ($short_name === null) {
return; // No short name available
// Only check if identifier has exactly 1 part
if (count($identifier_parts) !== 1) {
return;
}
// 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);
// Check if filename base matches directory name
$dir_parts = array_values(array_filter(explode('/', $dir_path)));
$current_dir = end($dir_parts);
if (!$is_full_name) {
return; // Not using full name
if (strtolower($filename_base) !== strtolower($current_dir)) {
return; // Filename doesn't match directory
}
// 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
// Get parent directory
if (count($dir_parts) < 2) {
return; // No parent directory to add
}
$parent_dir = $dir_parts[count($dir_parts) - 2];
// Construct suggested name: parent_current
$suggested_identifier = $parent_dir . '_' . $current_dir;
$suggested_filename = $is_rspade
? $suggested_identifier . '.' . $extension
: strtolower($suggested_identifier) . '.' . $extension;
// Check if suggested filename already exists
$suggested_path = dirname(base_path($file)) . '/' . $suggested_filename;
if (file_exists($suggested_path)) {
return; // Can't suggest - already taken
}
// Add violation
$identifier_type = $this->get_identifier_type($extension);
$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.",
"Filename is too generic - add parent directory context",
"$identifier_type: $identifier",
"The filename '$filename' is too generic (matches directory name only).\n" .
"Add parent directory context for clarity:\n" .
" mv '$filename' '$suggested_filename'\n\n" .
"IMPORTANT:\n" .
"- Only the filename changes - $identifier_type '$identifier' remains unchanged\n" .
"- Adds parent directory '$parent_dir' to provide essential context\n" .
"- Prevents ambiguous single-word filenames like 'edit.blade.php'\n\n" .
"Example: frontend/clients/edit/edit.blade.php\n" .
" → clients_edit.blade.php (adds 'clients' context)\n" .
" Now we know this is the edit view FOR clients, not a generic edit.\n\n" .
"This convention improves clarity by ensuring filenames provide sufficient context.",
'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)
* SUBTRACTIVE: Check directory for file groups with redundant prefixes across ALL extensions
* Groups files by common prefix and suggests removing directory-redundant parts
*/
private function extract_short_name(string $full_name, string $dir_path): ?string
private function check_subtractive_groups_all_extensions(string $dir_path, bool $is_rspade): void
{
$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;
$directory = base_path($dir_path);
if (!is_dir($directory)) {
return;
}
// If only 1 segment, no prefix to match
if ($original_segment_count === 1) {
return null;
// Find all files in directory across all supported extensions
$all_extensions = ['php', 'js', 'jqhtml', 'blade.php', 'scss'];
$all_files = [];
foreach ($all_extensions as $ext) {
$pattern = $directory . '/*.' . $ext;
$files = glob($pattern);
if ($files) {
$all_files = array_merge($all_files, $files);
}
}
// Split directory path into parts and re-index
$dir_parts = array_values(array_filter(explode('/', $dir_path)));
if (empty($all_files)) {
return;
}
// 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);
// Group files by their prefix (extension-agnostic grouping)
$groups = $this->group_files_by_prefix_all_extensions($all_files, $is_rspade);
// 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;
// Track which files were in groups (regardless of whether flagged)
$grouped_files = [];
$flagged_files = [];
// Check each group for redundant prefixes
foreach ($groups as $prefix => $file_info_list) {
// Track all files that are part of a group
foreach ($file_info_list as $file_info) {
$grouped_files[] = $file_info['file_path'];
}
$group_flagged = $this->check_prefix_group_all_extensions($prefix, $file_info_list, $dir_path, $is_rspade);
if ($group_flagged) {
// Track all files in this group as flagged
foreach ($file_info_list as $file_info) {
$flagged_files[] = $file_info['file_path'];
}
}
if ($all_match) {
$matched_parts = $num_to_check;
break;
}
// Check ungrouped files individually
// IMPORTANT: Only check files that were NOT part of any group
// Files in groups should never be checked individually, even if the group was rejected
foreach ($all_files as $file_path) {
if (!in_array($file_path, $grouped_files)) {
$this->check_individual_file_all_extensions($file_path, $dir_path, $is_rspade);
}
}
}
/**
* Check an individual file for redundant prefix (not part of a group) - extension-agnostic version
*/
private function check_individual_file_all_extensions(string $file_path, string $dir_path, bool $is_rspade): void
{
$filename = basename($file_path);
$extension = $this->detect_extension($filename);
$filename_base = $this->get_filename_base($filename, $extension);
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Create a single-file "group" and check it
$file_info = [
'file_path' => $file_path,
'filename' => $filename,
'filename_base' => $filename_base,
'extension' => $extension,
];
$this->check_prefix_group_all_extensions($filename_base, [$file_info], $dir_path, $is_rspade);
}
/**
* Detect the extension from a filename (handles .blade.php)
*/
private function detect_extension(string $filename): string
{
if (str_ends_with($filename, '.blade.php')) {
return 'blade.php';
}
return pathinfo($filename, PATHINFO_EXTENSION);
}
/**
* Group files by their common prefix (extension-agnostic version)
*
* Grouping Rule: Files are grouped if they share a common base where:
* - All files match: {base} or {base}_{one_word}
* - Works across ALL file extensions (.php, .js, .blade.php, .scss, etc.)
* - Example: frontend_calendar.js, frontend_calendar.blade.php, frontend_calendar_controller.php share base "frontend_calendar"
* - Example: frontend_calendar_event.js, frontend_calendar_event_controller.php share base "frontend_calendar_event"
* - Non-example: frontend_calendar and frontend_calendar_event are DIFFERENT bases
*/
private function group_files_by_prefix_all_extensions(array $files, bool $is_rspade): array
{
$file_bases = [];
// Extract filename bases
foreach ($files as $file_path) {
$filename = basename($file_path);
$extension = $this->detect_extension($filename);
$filename_base = $this->get_filename_base($filename, $extension);
$file_bases[] = [
'file_path' => $file_path,
'filename' => $filename,
'filename_base' => $filename_base,
'extension' => $extension,
];
}
// Find all possible group bases by checking each unique base
$unique_bases = array_unique(array_map(fn($f) => $is_rspade ? $f['filename_base'] : strtolower($f['filename_base']), $file_bases));
$groups = [];
foreach ($unique_bases as $potential_base) {
$potential_base_normalized = $is_rspade ? $potential_base : strtolower($potential_base);
// Find all files that match this base exactly OR have base + one word
$matching_files = array_filter($file_bases, function($file_info) use ($potential_base_normalized, $is_rspade) {
$file_base_normalized = $is_rspade ? $file_info['filename_base'] : strtolower($file_info['filename_base']);
// Exact match
if ($file_base_normalized === $potential_base_normalized) {
return true;
}
// Check if it's base + one word (base_something)
if (str_starts_with($file_base_normalized, $potential_base_normalized . '_')) {
// Extract the suffix after the base
$suffix = substr($file_base_normalized, strlen($potential_base_normalized) + 1);
// Only match if suffix is a single word (no more underscores)
if (!str_contains($suffix, '_')) {
return true;
}
}
return false;
});
// Only keep this group if it has 2+ files
if (count($matching_files) >= 2) {
$groups[$potential_base_normalized] = array_values($matching_files);
}
}
if ($matched_parts === 0) {
return null;
// Remove files from groups if they're in a more specific group
// Priority: shorter base names win (to ensure proper grouping of base + suffix files)
$sorted_groups = $groups;
uksort($sorted_groups, fn($a, $b) => strlen($a) - strlen($b)); // Shortest first
$assigned_files = [];
$final_groups = [];
foreach ($sorted_groups as $base => $files) {
$unassigned_files = array_filter($files, function($file) use ($assigned_files) {
return !in_array($file['file_path'], $assigned_files);
});
if (count($unassigned_files) >= 2) {
$final_groups[$base] = array_values($unassigned_files);
foreach ($unassigned_files as $file) {
$assigned_files[] = $file['file_path'];
}
}
}
// Calculate the short name
$short_parts = array_slice($name_parts, $matched_parts);
$short_segment_count = count($short_parts);
return $final_groups;
}
// 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
/**
* Group files by their common prefix (LEGACY - per extension)
*
* Grouping Rule: Files are grouped if they share a common base where:
* - All files match: {base} or {base}_{one_word}
* - Example: frontend_calendar_event, frontend_calendar_event_controller share base "frontend_calendar_event"
* - Example: frontend_calendar, frontend_calendar_controller share base "frontend_calendar"
* - Non-example: frontend_calendar and frontend_calendar_event are DIFFERENT bases
*/
private function group_files_by_prefix(array $files, string $extension, bool $is_rspade): array
{
$file_bases = [];
// Extract filename bases
foreach ($files as $file_path) {
$filename = basename($file_path);
$filename_base = $this->get_filename_base($filename, $extension);
$file_bases[] = [
'file_path' => $file_path,
'filename' => $filename,
'filename_base' => $filename_base,
];
}
return implode('_', $short_parts);
// Find all possible group bases by checking each unique base
$unique_bases = array_unique(array_map(fn($f) => $is_rspade ? $f['filename_base'] : strtolower($f['filename_base']), $file_bases));
$groups = [];
foreach ($unique_bases as $potential_base) {
$potential_base_normalized = $is_rspade ? $potential_base : strtolower($potential_base);
// Find all files that match this base exactly OR have base + one word
$matching_files = array_filter($file_bases, function($file_info) use ($potential_base_normalized, $is_rspade) {
$file_base_normalized = $is_rspade ? $file_info['filename_base'] : strtolower($file_info['filename_base']);
// Exact match
if ($file_base_normalized === $potential_base_normalized) {
return true;
}
// Check if it's base + one word (base_something)
if (str_starts_with($file_base_normalized, $potential_base_normalized . '_')) {
// Extract the suffix after the base
$suffix = substr($file_base_normalized, strlen($potential_base_normalized) + 1);
// Only match if suffix is a single word (no more underscores)
if (!str_contains($suffix, '_')) {
return true;
}
}
return false;
});
// Only keep this group if it has 2+ files
if (count($matching_files) >= 2) {
$groups[$potential_base_normalized] = array_values($matching_files);
}
}
// Remove files from groups if they're in a more specific group
// Priority: shorter base names win (to ensure proper grouping of base + suffix files)
$sorted_groups = $groups;
uksort($sorted_groups, fn($a, $b) => strlen($a) - strlen($b)); // Shortest first
$assigned_files = [];
$final_groups = [];
foreach ($sorted_groups as $base => $files) {
$unassigned_files = array_filter($files, function($file) use ($assigned_files) {
return !in_array($file['file_path'], $assigned_files);
});
if (count($unassigned_files) >= 2) {
$final_groups[$base] = array_values($unassigned_files);
foreach ($unassigned_files as $file) {
$assigned_files[] = $file['file_path'];
}
}
}
return $final_groups;
}
/**
* Check if a prefix group has redundant directory parts and should be shortened (extension-agnostic version)
*
* @return bool True if group was flagged, false otherwise
*/
private function check_prefix_group_all_extensions(string $prefix, array $file_info_list, string $dir_path, bool $is_rspade): bool
{
// Check if prefix has parts that can be removed
$prefix_parts = explode('_', $prefix);
$dir_parts = array_values(array_filter(explode('/', $dir_path)));
$immediate_dir = end($dir_parts);
$parent_dir = count($dir_parts) >= 2 ? $dir_parts[count($dir_parts) - 2] : null;
// Try to find how many parts from the prefix exist in directory path
$parts_to_remove = $this->count_redundant_prefix_parts($prefix_parts, $dir_parts);
if ($parts_to_remove === 0) {
return false; // No redundant parts
}
// WHITELIST: If removing parts would result in a 2-segment name where the first segment
// matches the parent directory, don't suggest the rename (preserves semantic overlap)
// Example: settings/password_security/settings_password_security.* should stay as-is
$short_parts = array_slice($prefix_parts, $parts_to_remove);
if (count($short_parts) === 2 && $parent_dir !== null) {
$first_segment = strtolower($short_parts[0]);
$parent_dir_lower = strtolower($parent_dir);
if ($first_segment === $parent_dir_lower) {
return false; // Preserve semantic overlap with parent directory
}
}
// Verify all files in group can be shortened consistently
$rename_suggestions = [];
foreach ($file_info_list as $file_info) {
$filename_parts = explode('_', $file_info['filename_base']);
// Remove the redundant parts
$short_name_parts = array_slice($filename_parts, $parts_to_remove);
$short_name = implode('_', $short_name_parts);
// CRITICAL: Single-segment filenames are NOT allowed
// If ANY file in the group would become a single segment, reject the entire group
if (count($short_name_parts) === 1) {
return false; // Single-segment filename not allowed - abort entire group
}
// Verify the short name preserves immediate directory context
$short_name_lower = strtolower($short_name);
$immediate_dir_lower = strtolower($immediate_dir);
if (!str_contains($short_name_lower, $immediate_dir_lower)) {
return false; // Would lose essential directory context - abort group rename
}
$extension = $file_info['extension'];
$short_filename = $is_rspade
? $short_name . '.' . $extension
: strtolower($short_name) . '.' . $extension;
// Check if short filename already exists
$short_path = dirname($file_info['file_path']) . '/' . $short_filename;
if (file_exists($short_path) && $short_path !== $file_info['file_path']) {
return false; // Conflict - can't rename group
}
$rename_suggestions[] = [
'old' => $file_info['filename'],
'new' => $short_filename,
'file_path' => str_replace(base_path() . '/', '', $file_info['file_path']),
];
}
// Generate single violation for the entire group
$this->generate_group_violation($rename_suggestions, $parts_to_remove, 'mixed');
return true; // Successfully flagged this group
}
/**
* Check if a prefix group has redundant directory parts and should be shortened (LEGACY - per extension)
*
* @return bool True if group was flagged, false otherwise
*/
private function check_prefix_group(string $prefix, array $file_info_list, string $dir_path, string $extension, bool $is_rspade): bool
{
// Check if prefix has parts that can be removed
$prefix_parts = explode('_', $prefix);
$dir_parts = array_values(array_filter(explode('/', $dir_path)));
$immediate_dir = end($dir_parts);
// Try to find how many parts from the prefix exist in directory path
$parts_to_remove = $this->count_redundant_prefix_parts($prefix_parts, $dir_parts);
if ($parts_to_remove === 0) {
return false; // No redundant parts
}
// Verify all files in group can be shortened consistently
$rename_suggestions = [];
foreach ($file_info_list as $file_info) {
$filename_parts = explode('_', $file_info['filename_base']);
// Remove the redundant parts
$short_name_parts = array_slice($filename_parts, $parts_to_remove);
$short_name = implode('_', $short_name_parts);
// CRITICAL: Single-segment filenames are NOT allowed
// If ANY file in the group would become a single segment, reject the entire group
if (count($short_name_parts) === 1) {
return false; // Single-segment filename not allowed - abort entire group
}
// Verify the short name preserves immediate directory context
$short_name_lower = strtolower($short_name);
$immediate_dir_lower = strtolower($immediate_dir);
if (!str_contains($short_name_lower, $immediate_dir_lower)) {
return false; // Would lose essential directory context - abort group rename
}
$short_filename = $is_rspade
? $short_name . '.' . $extension
: strtolower($short_name) . '.' . $extension;
// Check if short filename already exists
$short_path = dirname($file_info['file_path']) . '/' . $short_filename;
if (file_exists($short_path) && $short_path !== $file_info['file_path']) {
return false; // Conflict - can't rename group
}
$rename_suggestions[] = [
'old' => $file_info['filename'],
'new' => $short_filename,
'file_path' => str_replace(base_path() . '/', '', $file_info['file_path']),
];
}
// Generate single violation for the entire group
$this->generate_group_violation($rename_suggestions, $parts_to_remove, $extension);
return true; // Successfully flagged this group
}
/**
* Count how many parts from the prefix exist consecutively in the directory path
*
* IMPORTANT: This now uses the same logic as FilenameClassMatch's extract_short_name()
* which tries all possible short name lengths and returns the longest valid one.
* This ensures consistency between the convention rule and the strict naming rule.
*/
private function count_redundant_prefix_parts(array $prefix_parts, array $dir_parts): int
{
// Try all possible removal counts (from most to least, ensures we get longest valid short name)
// This matches the logic in FilenameClassMatch_CodeQualityRule::extract_short_name()
$original_segment_count = count($prefix_parts);
for ($short_len = $original_segment_count - 1; $short_len >= 2; $short_len--) {
$parts_to_remove = $original_segment_count - $short_len;
$prefix_sequence = array_slice($prefix_parts, 0, $parts_to_remove);
// Check if this prefix exists in the directory path
$prefix_len = count($prefix_sequence);
for ($start_idx = 0; $start_idx <= count($dir_parts) - $prefix_len; $start_idx++) {
$all_match = true;
for ($i = 0; $i < $prefix_len; $i++) {
if (strtolower($dir_parts[$start_idx + $i]) !== strtolower($prefix_sequence[$i])) {
$all_match = false;
break;
}
}
if ($all_match) {
// Found valid prefix - this is the number of parts we can remove
return $parts_to_remove;
}
}
}
return 0; // No removable parts found
}
/**
* Generate a single violation for an entire file group
*/
private function generate_group_violation(array $rename_suggestions, int $parts_removed, string $extension): void
{
// Use first file in group as the violation location
$first_file = $rename_suggestions[0];
// Build the violation message
$file_list = "";
foreach ($rename_suggestions as $suggestion) {
$file_list .= " {$suggestion['old']}{$suggestion['new']}\n";
}
$identifier_type = $this->get_identifier_type($extension);
$file_count = count($rename_suggestions);
$this->add_violation(
$first_file['file_path'],
1,
"File group has directory-redundant prefix that can be removed ($file_count files)",
"Grouped files sharing common prefix",
"The directory structure already provides context for these files.\n" .
"Suggested renames for entire group:\n\n$file_list\n" .
"IMPORTANT:\n" .
"- Only filenames change - $identifier_type names remain unchanged\n" .
"- Removes $parts_removed part(s) that duplicate directory path\n" .
"- ALL files in group must be renamed together to maintain visual grouping\n" .
"- Preserves immediate directory context for semantic clarity\n" .
"- Files with same prefix form a visual group that developers rely on\n\n" .
"Example group rename:\n" .
" rsx/app/frontend/calendar/\n" .
" frontend_calendar_event.blade.php → calendar_event.blade.php\n" .
" frontend_calendar_event.js → calendar_event.js\n" .
" frontend_calendar_event_controller.php → calendar_event_controller.php\n\n" .
"All files renamed together because they share 'frontend_calendar_event' prefix.\n" .
"The 'frontend' part is redundant (exists in directory path).\n\n" .
"This convention improves readability by removing redundancy while preserving\n" .
"semantic clarity and maintaining visual file grouping.",
'convention'
);
}
/**
* Extract filename base (without extension)
*/
private function get_filename_base(string $filename, string $extension): string
{
if ($extension === 'blade.php') {
return str_replace('.blade.php', '', $filename);
}
return pathinfo($filename, PATHINFO_FILENAME);
}
/**
* Get identifier type description for messages
*/
private function get_identifier_type(string $extension): string
{
if ($extension === 'blade.php') {
return '@rsx_id';
}
if ($extension === 'jqhtml') {
return '<Define>';
}
return 'class';
}
}