Files
rspade_system/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php
root ed8f24b26d Fix JS-NATIVE-01 violations in checkbox_multiselect
Add SCSS class scoping enforcement, move modal to lib, cleanup legacy files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 19:29:10 +00:00

359 lines
13 KiB
PHP
Executable File

<?php
namespace App\RSpade\CodeQuality\Rules\Manifest;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* ScssClassScope_CodeQualityRule - Enforces SCSS class scoping convention
*
* SCSS files in rsx/app/ and rsx/theme/components/ must be fully enclosed in a
* single top-level class selector that matches their associated JavaScript class
* or Blade view.
*
* Rules:
* - rsx/app/**\/*.scss → must match a Component subclass (action/layout/component) or Blade @rsx_id
* - rsx/theme/components/**\/*.scss → must match a Component subclass or Blade @rsx_id
* - Filename must match the associated file's filename (different extension)
* - EXCEPTION: Supplemental SCSS files may have different filenames if a primary SCSS file
* (with matching filename) already exists for that wrapper class. This allows splitting
* large stylesheets (e.g., by breakpoint or feature).
* - Other SCSS files are not validated by this rule
*
* NO EXEMPTIONS: Files in these paths MUST follow the convention. If a file truly
* cannot follow the pattern, it must be moved outside these directories (e.g., to
* rsx/theme/base/) but this requires explicit developer approval.
*/
class ScssClassScope_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'SCSS-SCOPE-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'SCSS Class Scope';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Enforces SCSS files in rsx/app/ and rsx/theme/components/ are scoped to their associated action/layout/component class';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.scss'];
}
/**
* This rule runs during manifest scan for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check SCSS files for proper class scoping
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
static $already_checked = false;
// Only check once per manifest build
if ($already_checked) {
return;
}
$already_checked = true;
// Get all manifest files
$files = Manifest::get_all();
if (empty($files)) {
return;
}
// Build lookup maps for quick matching
// Components include Spa_Action and Spa_Layout since they extend Component
$components = []; // class_name => file_path (without extension)
$blade_ids = []; // id => file_path (without extension)
foreach ($files as $file => $file_metadata) {
$extension = $file_metadata['extension'] ?? '';
// Collect Component subclasses (includes Spa_Action, Spa_Layout, and direct Component subclasses)
if ($extension === 'js') {
$class_name = $file_metadata['class'] ?? null;
if ($class_name) {
try {
if (Manifest::js_is_subclass_of($class_name, 'Component')) {
$components[$class_name] = pathinfo($file, PATHINFO_FILENAME);
}
} catch (\Exception $e) {
// Class not found in inheritance chain, skip
}
}
}
// Collect Blade @rsx_id values
if ($extension === 'blade.php') {
$id = $file_metadata['id'] ?? null;
if ($id) {
// Remove .blade.php to get base filename
$filename = basename($file, '.blade.php');
$blade_ids[$id] = $filename;
}
}
// Collect jqhtml component IDs (from <Define:Component_Name>)
// These are Components without a companion .js file
if ($extension === 'jqhtml') {
$id = $file_metadata['id'] ?? null;
if ($id) {
// Check if there's already a .js file for this component
// If so, skip - the .js file takes precedence
if (!isset($components[$id])) {
$filename = pathinfo($file, PATHINFO_FILENAME);
$components[$id] = $filename;
}
}
}
}
// Build map of wrapper classes that have a primary SCSS file (filename matches)
// This allows supplemental SCSS files with different names for the same wrapper class
$wrapper_classes_with_primary_scss = [];
foreach ($files as $file => $file_metadata) {
$extension = $file_metadata['extension'] ?? '';
if ($extension !== 'scss') {
continue;
}
$wrapper_class = $file_metadata['scss_wrapper_class'] ?? null;
if ($wrapper_class === null) {
continue;
}
$scss_filename = pathinfo($file, PATHINFO_FILENAME);
// Check if this SCSS file's name matches its wrapper class's associated file
$matched_filename = $components[$wrapper_class] ?? $blade_ids[$wrapper_class] ?? null;
if ($matched_filename !== null && strcasecmp($scss_filename, $matched_filename) === 0) {
$wrapper_classes_with_primary_scss[$wrapper_class] = true;
}
}
// Now validate SCSS files
foreach ($files as $file => $file_metadata) {
$extension = $file_metadata['extension'] ?? '';
if ($extension !== 'scss') {
continue;
}
// Determine which validation to apply based on path
$is_rsx_app = str_starts_with($file, 'rsx/app/');
$is_theme_component = str_starts_with($file, 'rsx/theme/components/');
// Skip files outside our enforcement paths
if (!$is_rsx_app && !$is_theme_component) {
continue;
}
// NO EXEMPTIONS - all files in these paths must follow the convention
// Skip files that contain only variables and comments (no actual rules)
if (!empty($file_metadata['scss_variables_only'])) {
continue;
}
$wrapper_class = $file_metadata['scss_wrapper_class'] ?? null;
$scss_filename = pathinfo($file, PATHINFO_FILENAME);
$this->validate_scss_file($file, $wrapper_class, $scss_filename, $components, $blade_ids, $wrapper_classes_with_primary_scss);
}
}
/**
* Validate SCSS file against Component classes and Blade @rsx_id values
*/
private function validate_scss_file(
string $file,
?string $wrapper_class,
string $scss_filename,
array $components,
array $blade_ids,
array $wrapper_classes_with_primary_scss
): void {
// Check if file has a wrapper class
if ($wrapper_class === null) {
$this->add_violation(
$file,
1,
"SCSS file must be fully enclosed in a single class rule (e.g., .My_Component { ... })",
basename($file),
$this->build_no_wrapper_suggestion($file),
'critical'
);
return;
}
// Check if wrapper class matches a Component or Blade @rsx_id
$matched_filename = null;
$match_type = null;
if (isset($components[$wrapper_class])) {
$matched_filename = $components[$wrapper_class];
$match_type = 'Component';
} elseif (isset($blade_ids[$wrapper_class])) {
$matched_filename = $blade_ids[$wrapper_class];
$match_type = 'Blade @rsx_id';
}
if ($matched_filename === null) {
$this->add_violation(
$file,
1,
"SCSS wrapper class '{$wrapper_class}' does not match any Component class or Blade @rsx_id",
".{$wrapper_class} { ... }",
$this->build_no_match_suggestion($file, $wrapper_class),
'critical'
);
return;
}
// Check if filename matches
if (strcasecmp($scss_filename, $matched_filename) !== 0) {
// Allow supplemental SCSS files if a primary file already exists for this wrapper class
if (isset($wrapper_classes_with_primary_scss[$wrapper_class])) {
// This is a supplemental file - filename mismatch is allowed
return;
}
$this->add_violation(
$file,
1,
"SCSS filename '{$scss_filename}.scss' must match associated {$match_type} file '{$matched_filename}'. " .
"Supplemental files with different names are allowed only if a primary file ('{$matched_filename}.scss') exists. " .
"See: php artisan rsx:man scss",
basename($file),
$this->build_filename_mismatch_suggestion($file, $scss_filename, $matched_filename, $match_type),
'critical'
);
}
}
/**
* Build suggestion for files without a wrapper class
*/
private function build_no_wrapper_suggestion(string $file): string
{
$lines = [];
$lines[] = "SCSS files in rsx/app/ and rsx/theme/components/ must be fully enclosed";
$lines[] = "in a single top-level class selector that matches their associated";
$lines[] = "action, layout, component, or Blade view.";
$lines[] = "";
$lines[] = "This prevents CSS conflicts and ensures styles are scoped to the";
$lines[] = "element they are intended to style.";
$lines[] = "";
$lines[] = "VALID ASSOCIATIONS:";
$lines[] = " - Spa_Action class (e.g., Frontend_Dashboard extends Spa_Action)";
$lines[] = " - Spa_Layout class (e.g., Frontend_Layout extends Spa_Layout)";
$lines[] = " - Component class (e.g., Sidebar_Nav extends Component)";
$lines[] = " - Blade @rsx_id (e.g., @rsx_id('Login_Index'))";
$lines[] = "";
$lines[] = "TO FIX:";
$lines[] = " Wrap ALL rules in the associated class:";
$lines[] = "";
$lines[] = " .My_Component {";
$lines[] = " // ALL styles go here";
$lines[] = " .card { ... }";
$lines[] = " .button { ... }";
$lines[] = " }";
$lines[] = "";
$lines[] = "NO EXEMPTIONS are allowed in these directories. If this file cannot";
$lines[] = "be associated to an action/layout/component/view, it may need to be";
$lines[] = "moved to rsx/theme/ (outside components/) - but this is rare and";
$lines[] = "requires explicit developer approval.";
$lines[] = "";
$lines[] = "See: php artisan rsx:man scss";
return implode("\n", $lines);
}
/**
* Build suggestion for wrapper class that doesn't match anything
*/
private function build_no_match_suggestion(string $file, string $wrapper_class): string
{
$lines = [];
$lines[] = "The wrapper class '{$wrapper_class}' does not match any known";
$lines[] = "action, layout, component, or Blade view.";
$lines[] = "";
$lines[] = "VALID ASSOCIATIONS:";
$lines[] = " - Spa_Action class (e.g., Frontend_Dashboard extends Spa_Action)";
$lines[] = " - Spa_Layout class (e.g., Frontend_Layout extends Spa_Layout)";
$lines[] = " - Component class (e.g., Sidebar_Nav extends Component)";
$lines[] = " - Blade @rsx_id (e.g., @rsx_id('Login_Index'))";
$lines[] = "";
$lines[] = "TO FIX:";
$lines[] = " 1. Rename the wrapper class to match your action/layout/component/view";
$lines[] = " 2. Or create the missing .js or .blade.php file with this class/id";
$lines[] = "";
$lines[] = "NO EXEMPTIONS are allowed. If this file provides shared styles that";
$lines[] = "cannot be scoped to a single element, it may need to be moved to";
$lines[] = "rsx/theme/ (outside components/) - but this is rare and requires";
$lines[] = "explicit developer approval.";
$lines[] = "";
$lines[] = "See: php artisan rsx:man scss";
return implode("\n", $lines);
}
/**
* Build suggestion for filename mismatch
*/
private function build_filename_mismatch_suggestion(
string $file,
string $scss_filename,
string $expected_filename,
string $match_type
): string {
$lines = [];
$lines[] = "SCSS filename must match the associated {$match_type} file.";
$lines[] = "";
$lines[] = "Current: {$scss_filename}.scss";
$lines[] = "Expected: {$expected_filename}.scss";
$lines[] = "";
$lines[] = "TO FIX:";
$lines[] = " Rename the SCSS file:";
$lines[] = " mv {$scss_filename}.scss {$expected_filename}.scss";
$lines[] = "";
$lines[] = "See: php artisan rsx:man scss";
return implode("\n", $lines);
}
}