Add 100+ automated unit tests from .expect file specifications Add session system test Add rsx:constants:regenerate command test Add rsx:logrotate command test Add rsx:clean command test Add rsx:manifest:stats command test Add model enum system test Add model mass assignment prevention test Add rsx:check command test Add migrate:status command test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
956 lines
40 KiB
PHP
956 lines
40 KiB
PHP
<?php
|
|
|
|
namespace App\RSpade\CodeQuality\Rules\Convention;
|
|
|
|
use App\Constants;
|
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
|
|
|
/**
|
|
* FilenameRedundantPrefix_CodeQualityRule - Enforces optimal filename clarity
|
|
*
|
|
* 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';
|
|
}
|
|
|
|
public function get_name(): string
|
|
{
|
|
return 'Filename Clarity Convention';
|
|
}
|
|
|
|
public function get_description(): string
|
|
{
|
|
return 'Suggests optimal filename clarity by adding or removing directory context';
|
|
}
|
|
|
|
public function get_file_patterns(): array
|
|
{
|
|
return ['*.php', '*.js', '*.jqhtml', '*.blade.php'];
|
|
}
|
|
|
|
public function get_default_severity(): string
|
|
{
|
|
return 'convention';
|
|
}
|
|
|
|
public function check(string $file_path, string $contents, array $metadata = []): void
|
|
{
|
|
// Only check files in ./rsx or ./app/RSpade
|
|
$relative_path = str_replace(base_path() . '/', '', $file_path);
|
|
$is_rsx = str_starts_with($relative_path, 'rsx/');
|
|
$is_rspade = str_starts_with($relative_path, 'app/RSpade/');
|
|
|
|
if (!$is_rsx && !$is_rspade) {
|
|
return;
|
|
}
|
|
|
|
$extension = $metadata['extension'] ?? '';
|
|
$filename = basename($file_path);
|
|
$dir_path = dirname($relative_path);
|
|
$dir_key = $dir_path; // Check per directory, not per extension
|
|
|
|
// 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'])) {
|
|
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 '<Define>';
|
|
}
|
|
return 'class';
|
|
}
|
|
}
|