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'])) { return $metadata['class']; } // Blade/jqhtml files: use id if (($extension === 'blade.php' || $extension === 'jqhtml') && isset($metadata['id'])) { return $metadata['id']; } return null; } /** * 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_base = $this->get_filename_base($filename, $extension); $identifier_parts = explode('_', $identifier); // Only check if identifier has exactly 1 part if (count($identifier_parts) !== 1) { return; } // Check if filename base matches directory name $dir_parts = array_values(array_filter(explode('/', $dir_path))); $current_dir = end($dir_parts); if (strtolower($filename_base) !== strtolower($current_dir)) { return; // Filename doesn't match directory } // 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 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' ); } /** * 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 check_subtractive_groups_all_extensions(string $dir_path, bool $is_rspade): void { $directory = base_path($dir_path); if (!is_dir($directory)) { return; } // 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); } } if (empty($all_files)) { return; } // Group files by their prefix (extension-agnostic grouping) $groups = $this->group_files_by_prefix_all_extensions($all_files, $is_rspade); // 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']; } } } // 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); } } // 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; } /** * 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, ]; } // 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 ''; } return 'class'; } }