🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
248 lines
8.5 KiB
PHP
Executable File
248 lines
8.5 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\RSpade\CodeQuality\Rules\Jqhtml;
|
|
|
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
|
|
|
/**
|
|
* JQHTML Redundant Class Rule
|
|
*
|
|
* Detects when a component's class name (or kebab-case equivalent) is redundantly
|
|
* specified in the class attribute of the Define tag. Component names are automatically
|
|
* added to the rendered element's class list, so explicitly including them is unnecessary.
|
|
*
|
|
* Detects both:
|
|
* <Define:Breadcrumb_Nav class="Breadcrumb_Nav"> // Exact match
|
|
* <Define:Breadcrumb_Nav class="breadcrumb-nav"> // Kebab-case equivalent
|
|
*/
|
|
class JqhtmlRedundantClass_CodeQualityRule extends CodeQualityRule_Abstract
|
|
{
|
|
/**
|
|
* Get the unique identifier for this rule
|
|
*/
|
|
public function get_id(): string
|
|
{
|
|
return 'JQHTML-CLASS-01';
|
|
}
|
|
|
|
/**
|
|
* Get the human-readable name of this rule
|
|
*/
|
|
public function get_name(): string
|
|
{
|
|
return 'JQHTML Redundant Component Class Name';
|
|
}
|
|
|
|
/**
|
|
* Get the description of what this rule checks
|
|
*/
|
|
public function get_description(): string
|
|
{
|
|
return 'Detects when component class name (or kebab-case equivalent) is redundantly specified in class attribute';
|
|
}
|
|
|
|
/**
|
|
* Get file patterns this rule should check
|
|
*/
|
|
public function get_file_patterns(): array
|
|
{
|
|
return ['*.jqhtml'];
|
|
}
|
|
|
|
/**
|
|
* This rule runs during manifest scan for immediate feedback
|
|
*/
|
|
public function is_called_during_manifest_scan(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get the default severity level
|
|
*/
|
|
public function get_default_severity(): string
|
|
{
|
|
return 'medium';
|
|
}
|
|
|
|
/**
|
|
* Check the file for violations
|
|
*/
|
|
public function check(string $file_path, string $contents, array $metadata = []): void
|
|
{
|
|
$lines = explode("\n", $contents);
|
|
$line_number = 0;
|
|
|
|
foreach ($lines as $line) {
|
|
$line_number++;
|
|
|
|
// Look for <Define:ComponentName ... class="..."> patterns
|
|
// Match Define tag with component name and class attribute on same line
|
|
if (preg_match('/<Define:([a-zA-Z_][a-zA-Z0-9_]*)\s+[^>]*class=["\']([^"\']*)["\']/', $line, $matches)) {
|
|
$component_name = $matches[1];
|
|
$class_attribute = $matches[2];
|
|
$kebab_case = $this->to_kebab_case($component_name);
|
|
|
|
// Split class attribute into individual classes
|
|
$classes = preg_split('/\s+/', trim($class_attribute));
|
|
|
|
foreach ($classes as $class) {
|
|
$class = trim($class);
|
|
if (empty($class)) {
|
|
continue;
|
|
}
|
|
|
|
// Check for exact match with component name
|
|
if ($class === $component_name) {
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Redundant class=\"{$component_name}\" on component {$component_name}. " .
|
|
"The component automatically gets class=\"{$component_name}\" assigned to it.",
|
|
trim($line),
|
|
$this->build_exact_match_suggestion($component_name, $class_attribute),
|
|
'medium'
|
|
);
|
|
}
|
|
// Check for kebab-case equivalent (case-insensitive)
|
|
// Only flag if component name has underscores (multi-word), since single-word
|
|
// lowercase classes like "card" could be legitimate external CSS framework classes
|
|
// Also skip known CSS framework class prefixes (Bootstrap, etc.)
|
|
elseif (strpos($component_name, '_') !== false
|
|
&& strtolower($class) === $kebab_case
|
|
&& !$this->is_known_framework_class($class)) {
|
|
$this->add_violation(
|
|
$file_path,
|
|
$line_number,
|
|
"Unnecessary class=\"{$class}\" on component {$component_name}. " .
|
|
"The component automatically gets class=\"{$component_name}\" assigned to it. " .
|
|
"Use .{$component_name} in CSS/JS selectors instead of .{$class}",
|
|
trim($line),
|
|
$this->build_kebab_case_suggestion($component_name, $class),
|
|
'medium'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert PascalCase_With_Underscores to kebab-case
|
|
*
|
|
* My_Component -> my-component
|
|
* Breadcrumb_Nav -> breadcrumb-nav
|
|
*/
|
|
private function to_kebab_case(string $component_name): string
|
|
{
|
|
return strtolower(str_replace('_', '-', $component_name));
|
|
}
|
|
|
|
/**
|
|
* Check if a class name is a known CSS framework class
|
|
*
|
|
* These are legitimate uses of kebab-case classes from external frameworks
|
|
* like Bootstrap that happen to match component names.
|
|
*/
|
|
private function is_known_framework_class(string $class): bool
|
|
{
|
|
// Bootstrap class prefixes that are commonly used
|
|
$bootstrap_prefixes = [
|
|
'card-',
|
|
'btn-',
|
|
'nav-',
|
|
'navbar-',
|
|
'form-',
|
|
'input-',
|
|
'list-',
|
|
'table-',
|
|
'modal-',
|
|
'alert-',
|
|
'badge-',
|
|
'dropdown-',
|
|
'breadcrumb-',
|
|
'pagination-',
|
|
'progress-',
|
|
'spinner-',
|
|
'toast-',
|
|
'tooltip-',
|
|
'popover-',
|
|
'carousel-',
|
|
'accordion-',
|
|
'offcanvas-',
|
|
'placeholder-',
|
|
];
|
|
|
|
foreach ($bootstrap_prefixes as $prefix) {
|
|
if (strpos($class, $prefix) === 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build suggestion for exact component name match
|
|
*/
|
|
private function build_exact_match_suggestion(string $component_name, string $class_attribute): string
|
|
{
|
|
$cleaned = $this->remove_class_from_attribute($class_attribute, $component_name);
|
|
|
|
$lines = [];
|
|
$lines[] = "Remove '{$component_name}' from the class attribute. Component names are";
|
|
$lines[] = "automatically added to the rendered element's class list by the jqhtml framework.";
|
|
$lines[] = "";
|
|
$lines[] = "CURRENT:";
|
|
$lines[] = " <Define:{$component_name} class=\"{$class_attribute}\">";
|
|
$lines[] = "";
|
|
if (empty($cleaned)) {
|
|
$lines[] = "CORRECTED:";
|
|
$lines[] = " <Define:{$component_name}>";
|
|
} else {
|
|
$lines[] = "CORRECTED:";
|
|
$lines[] = " <Define:{$component_name} class=\"{$cleaned}\">";
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Build suggestion for kebab-case equivalent
|
|
*/
|
|
private function build_kebab_case_suggestion(string $component_name, string $redundant_class): string
|
|
{
|
|
$lines = [];
|
|
$lines[] = "The class \"{$redundant_class}\" is redundant because jqhtml components";
|
|
$lines[] = "automatically receive their component name as a CSS class.";
|
|
$lines[] = "";
|
|
$lines[] = "When you write:";
|
|
$lines[] = " <Define:{$component_name} class=\"{$redundant_class}\">";
|
|
$lines[] = "";
|
|
$lines[] = "The rendered element becomes:";
|
|
$lines[] = " <div class=\"{$component_name} {$redundant_class}\">";
|
|
$lines[] = "";
|
|
$lines[] = "TO FIX:";
|
|
$lines[] = " 1. Remove the redundant class from the Define tag:";
|
|
$lines[] = " <Define:{$component_name}>";
|
|
$lines[] = "";
|
|
$lines[] = " 2. Update any CSS/SCSS selectors to use the component class:";
|
|
$lines[] = " .{$component_name} { ... } // Instead of .{$redundant_class}";
|
|
$lines[] = "";
|
|
$lines[] = " 3. Update any JavaScript selectors:";
|
|
$lines[] = " \$('.{$component_name}') // Instead of \$('.{$redundant_class}')";
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Remove a class from the class attribute string
|
|
*/
|
|
private function remove_class_from_attribute(string $class_attribute, string $class_to_remove): string
|
|
{
|
|
$cleaned = preg_replace('/\b' . preg_quote($class_to_remove, '/') . '\b\s*/', '', $class_attribute);
|
|
$cleaned = preg_replace('/\s+/', ' ', $cleaned);
|
|
return trim($cleaned);
|
|
}
|
|
}
|