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 { // Skip additive suggestions for framework code - canonical single-word class names // like Ajax, Manifest, Session, Task are intentional and shouldn't be renamed if ($is_rspade) { return; } $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 } // CRITICAL: Validate against class/identifier name using MANIFEST-FILENAME-01 logic // The suggested filename must still be valid for the class name $suggested_base = $is_rspade ? $suggested_identifier : strtolower($suggested_identifier); if (!$this->is_valid_short_filename($suggested_base, $identifier, $dir_path, $is_rspade)) { return; // Suggested rename would violate MANIFEST-FILENAME-01 - skip suggestion } // 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); // Before checking this file, verify that ALL files sharing the same prefix can be // consistently renamed. If not, renaming just this file would break visual grouping. if (!$this->can_all_siblings_with_prefix_be_renamed($file_path, $filename_base, $dir_path, $is_rspade)) { return; // Skip - would break visual grouping with sibling files } // 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); } /** * Check if ALL files in the directory sharing the same prefix can be consistently renamed. * This prevents breaking visual grouping when some files can be shortened and others cannot. * * Example: settings_general_action.js and settings_general_controller.php share prefix "settings_general_". * If the action can remove "settings_" but the controller cannot (due to different class name), * we should NOT suggest renaming either to maintain visual grouping. */ private function can_all_siblings_with_prefix_be_renamed(string $file_path, string $filename_base, string $dir_path, bool $is_rspade): bool { $directory = dirname($file_path); // Determine what prefix would be removed from this file $filename_parts = explode('_', $filename_base); $dir_parts = array_values(array_filter(explode('/', $dir_path))); $parts_to_remove = $this->count_redundant_prefix_parts($filename_parts, $dir_parts); if ($parts_to_remove === 0) { return true; // No prefix to remove, so no grouping concern } // The prefix that would be removed (e.g., "settings_" from "settings_general_action") $prefix_to_remove = implode('_', array_slice($filename_parts, 0, $parts_to_remove)) . '_'; // Find all files in directory that share this prefix $all_extensions = ['php', 'js', 'jqhtml', 'blade.php', 'scss']; $sibling_files = []; foreach ($all_extensions as $ext) { $pattern = $directory . '/*.' . $ext; $files = glob($pattern); if ($files) { foreach ($files as $sibling_path) { $sibling_filename = basename($sibling_path); $sibling_base = $this->get_filename_base($sibling_filename, $ext); // Check if sibling shares the same prefix $sibling_base_normalized = $is_rspade ? $sibling_base : strtolower($sibling_base); $prefix_normalized = $is_rspade ? rtrim($prefix_to_remove, '_') : strtolower(rtrim($prefix_to_remove, '_')); if (str_starts_with($sibling_base_normalized, $prefix_normalized . '_')) { $sibling_files[] = [ 'file_path' => $sibling_path, 'filename_base' => $sibling_base, 'extension' => $ext, ]; } } } } // If no siblings share the prefix, no grouping concern if (count($sibling_files) <= 1) { return true; } // Check if ALL siblings can have the same prefix removed foreach ($sibling_files as $sibling) { $sibling_parts = explode('_', $sibling['filename_base']); $short_name_parts = array_slice($sibling_parts, $parts_to_remove); $short_name = implode('_', $short_name_parts); // Skip if would become single-segment if (count($short_name_parts) < 2) { return false; // Can't rename consistently - would create single-segment filename } // Get class name for this sibling $relative_path = str_replace(base_path() . '/', '', $sibling['file_path']); $file_metadata = \App\RSpade\Core\Manifest\Manifest::get_file($relative_path); $class_name = $file_metadata['class'] ?? $file_metadata['id'] ?? null; if ($class_name !== null) { $suggested_base = $is_rspade ? $short_name : strtolower($short_name); if (!$this->is_valid_short_filename($suggested_base, $class_name, $dir_path, $is_rspade)) { return false; // This sibling can't be renamed - would violate manifest rule } } } return true; // All siblings can be consistently renamed } /** * 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); // Generic operation directories - renaming files in these creates proliferation // of identical filenames across the codebase (e.g., many "edit_action.js" files) if (in_array($immediate_dir_lower, Constants::GENERIC_OPERATIONS)) { $first_segment = strtolower($short_name_parts[0] ?? ''); if ($first_segment === $immediate_dir_lower) { return false; // Would create generic operation filename proliferation - abort } } 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 } // CRITICAL: Validate against class name using MANIFEST-FILENAME-01 logic // Get the class/identifier name from manifest $relative_path = str_replace(base_path() . '/', '', $file_info['file_path']); $file_metadata = \App\RSpade\Core\Manifest\Manifest::get_file($relative_path); $class_name = $file_metadata['class'] ?? $file_metadata['id'] ?? null; if ($class_name !== null) { // Check if the suggested short filename would be valid for this class $suggested_base = $is_rspade ? $short_name : strtolower($short_name); if (!$this->is_valid_short_filename($suggested_base, $class_name, $dir_path, $is_rspade)) { return false; // Suggested rename would violate MANIFEST-FILENAME-01 - abort } } $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); // Generic operation directories - renaming files in these creates proliferation // of identical filenames across the codebase (e.g., many "edit_action.js" files) if (in_array($immediate_dir_lower, Constants::GENERIC_OPERATIONS)) { $first_segment = strtolower($short_name_parts[0] ?? ''); if ($first_segment === $immediate_dir_lower) { return false; // Would create generic operation filename proliferation - abort } } 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 } // CRITICAL: Validate against class name using MANIFEST-FILENAME-01 logic // Get the class/identifier name from manifest $relative_path = str_replace(base_path() . '/', '', $file_info['file_path']); $file_metadata = \App\RSpade\Core\Manifest\Manifest::get_file($relative_path); $class_name = $file_metadata['class'] ?? $file_metadata['id'] ?? null; if ($class_name !== null) { // Check if the suggested short filename would be valid for this class $suggested_base = $is_rspade ? $short_name : strtolower($short_name); if (!$this->is_valid_short_filename($suggested_base, $class_name, $dir_path, $is_rspade)) { return false; // Suggested rename would violate MANIFEST-FILENAME-01 - abort } } $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); } /** * Extract valid short name from class/id by checking if directory + filename parts match full name * This is the same logic as FilenameClassMatch_CodeQualityRule::extract_short_name() * * Returns the valid short name if one exists, or null if only full name is valid. */ private function extract_valid_short_name(string $full_name, string $dir_path): ?string { // Short names only allowed in ./rsx directory, not in framework code (/app/RSpade) if (str_contains($dir_path, '/app/RSpade') || str_contains($dir_path, 'app/RSpade')) { return null; } // Split the full name by underscores $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; } // If only 1 segment, no prefix to match if ($original_segment_count === 1) { return null; } // Split directory path into parts and re-index $dir_parts = array_values(array_filter(explode('/', $dir_path))); // Try all possible short name lengths (from longest to shortest, minimum 2 segments) // For each potential short name, check if directory contains the complementary prefix parts for ($short_len = $original_segment_count - 1; $short_len >= 2; $short_len--) { $short_parts = array_slice($name_parts, $original_segment_count - $short_len); $prefix_parts = array_slice($name_parts, 0, $original_segment_count - $short_len); // Check if prefix_parts exist as a contiguous sequence in directory $prefix_len = count($prefix_parts); 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_parts[$i])) { $all_match = false; break; } } if ($all_match) { // Found valid prefix in directory, this short name is valid return implode('_', $short_parts); } } } return null; // No valid short name found } /** * Check if a suggested short filename would be valid for a given class name * Uses the same validation logic as MANIFEST-FILENAME-01 */ private function is_valid_short_filename(string $suggested_filename_base, string $class_name, string $dir_path, bool $is_rspade): bool { // Full name always valid $matches_full = $is_rspade ? $suggested_filename_base === $class_name : strtolower($suggested_filename_base) === strtolower($class_name); if ($matches_full) { return true; } // Check if short name is valid $valid_short_name = $this->extract_valid_short_name($class_name, $dir_path); if ($valid_short_name !== null) { $matches_short = $is_rspade ? $suggested_filename_base === $valid_short_name : strtolower($suggested_filename_base) === strtolower($valid_short_name); if ($matches_short) { return true; } } return false; } /** * 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'; } }