Files
rspade_system/app/RSpade/CodeQuality/Rules/JavaScript/JqhtmlCatchFallback_CodeQualityRule.php
root 29c657f7a7 Exclude tests directory from framework publish
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>
2025-12-25 03:59:58 +00:00

366 lines
13 KiB
PHP

<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
/**
* Detect fallback data assignments in catch blocks within on_load() methods
*
* This is a CRITICAL violation of the fail-loud principle. When a network error
* occurs during data loading, the application should SHOW THE ERROR, not silently
* substitute hardcoded values that will cause bizarre undefined behavior.
*
* FORBIDDEN PATTERN:
* async on_load() {
* try {
* this.data.roles = await Controller.get_roles();
* } catch (e) {
* this.data.roles = [{id: 1, name: 'Manager'}]; // VIOLATION
* }
* }
*
* CORRECT PATTERN:
* async on_load() {
* try {
* this.data.roles = await Controller.get_roles();
* } catch (e) {
* this.data.error_data = e; // Surface the error
* }
* }
*
* This is like putting black tape over a car's check engine light. The error
* is hidden, never noticed, never fixed. The application shows "working" UI
* but the data is stale/wrong/invented.
*/
class JqhtmlCatchFallback_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Allowed property names for this.data.X = in catch blocks
*/
private const ALLOWED_ERROR_PROPERTIES = [
'error',
'error_data',
'error_message',
'load_error',
'loading_error',
'fetch_error',
];
/**
* Properties that can be set to boolean/null values in catch blocks
* These are state flags, not data
*/
private const ALLOWED_STATE_PROPERTIES = [
'loading',
'loaded',
'is_loading',
'is_loaded',
'fetching',
'refreshing',
];
public function get_id(): string
{
return 'JS-CATCH-FALLBACK-01';
}
public function get_name(): string
{
return 'Catch Block Fallback Data Assignment';
}
public function get_description(): string
{
return 'Prohibits assigning fallback data in catch blocks - errors must be surfaced, not hidden';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
/**
* Run during manifest build for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
public function get_default_severity(): string
{
return 'critical';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a JavaScript file
if (!isset($metadata['extension']) || $metadata['extension'] !== 'js') {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Check if this is a Component subclass (Component, Spa_Action, Spa_Layout, or their children)
$extends = $metadata['extends'] ?? null;
if (!in_array($extends, ['Component', 'Spa_Action', 'Spa_Layout'])) {
return;
}
// Check for file-level exception
if (str_contains($contents, '@' . $this->get_id() . '-EXCEPTION')) {
return;
}
// Get sanitized content (comments removed)
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_content = $sanitized_data['content'];
// Get class name
$class_name = $metadata['class'] ?? 'Unknown';
// Find the on_load method
if (!preg_match('/(?:async\s+)?on_load\s*\([^)]*\)\s*\{/i', $sanitized_content, $method_match, PREG_OFFSET_CAPTURE)) {
return; // No on_load method
}
$method_start = $method_match[0][1];
// Extract the on_load method body
$method_body = $this->extract_brace_block($sanitized_content, $method_start);
if (empty($method_body)) {
return;
}
// Find catch blocks within on_load
$this->check_catch_blocks($file_path, $contents, $method_body, $method_start, $class_name);
}
/**
* Find and check all catch blocks within the on_load method
*/
private function check_catch_blocks(string $file_path, string $original_contents, string $method_body, int $method_offset, string $class_name): void
{
// Find all catch blocks
$offset = 0;
while (preg_match('/\bcatch\s*\([^)]*\)\s*\{/', $method_body, $match, PREG_OFFSET_CAPTURE, $offset)) {
$catch_start = $match[0][1];
$catch_body = $this->extract_brace_block($method_body, $catch_start);
if (!empty($catch_body)) {
$this->check_catch_body($file_path, $original_contents, $catch_body, $method_offset + $catch_start, $class_name);
}
// Move past this catch block
$offset = $catch_start + strlen($match[0][0]);
}
}
/**
* Check a catch block body for forbidden data assignments
*/
private function check_catch_body(string $file_path, string $original_contents, string $catch_body, int $catch_offset, string $class_name): void
{
$lines = explode("\n", $catch_body);
$original_lines = explode("\n", $original_contents);
// Calculate line offset from file start
$line_offset = substr_count(substr($original_contents, 0, $catch_offset), "\n");
foreach ($lines as $line_num => $line) {
$actual_line_number = $line_offset + $line_num + 1;
$trimmed = trim($line);
// Skip empty lines
if (empty($trimmed)) {
continue;
}
// Check for this.data.X = patterns
if (preg_match('/\bthis\.data\.(\w+)\s*=\s*(.+)/', $line, $matches)) {
$property_name = $matches[1];
$value_part = trim($matches[2]);
// Allow error-related properties
if (in_array($property_name, self::ALLOWED_ERROR_PROPERTIES)) {
continue;
}
// Allow any property to be set to null (clearing data is OK)
if (preg_match('/^null\s*[;,]?\s*$/', $value_part)) {
continue;
}
// Allow state properties when set to boolean
if (in_array($property_name, self::ALLOWED_STATE_PROPERTIES)) {
if (preg_match('/^(true|false)\s*[;,]?\s*$/', $value_part)) {
continue;
}
}
// Check for line-level exception in original file
if ($this->line_has_exception($original_lines, $actual_line_number)) {
continue;
}
// Get the actual line from original content for display
$display_line = isset($original_lines[$actual_line_number - 1])
? trim($original_lines[$actual_line_number - 1])
: trim($line);
$this->add_violation(
$file_path,
$actual_line_number,
"CRITICAL: Fallback data assigned in catch block. Setting 'this.data.{$property_name}' hides the error instead of surfacing it.",
$display_line,
$this->build_suggestion($property_name, $class_name),
'critical'
);
}
// Also check for this.data = (full object assignment)
if (preg_match('/\bthis\.data\s*=\s*\{/', $line)) {
// Check for line-level exception in original file
if ($this->line_has_exception($original_lines, $actual_line_number)) {
continue;
}
$display_line = isset($original_lines[$actual_line_number - 1])
? trim($original_lines[$actual_line_number - 1])
: trim($line);
$this->add_violation(
$file_path,
$actual_line_number,
"CRITICAL: Fallback data object assigned in catch block. This hides the error instead of surfacing it.",
$display_line,
$this->build_suggestion('...', $class_name),
'critical'
);
}
}
}
/**
* Extract a brace-delimited block starting at the given position
*/
private function extract_brace_block(string $content, int $start_pos): string
{
// Find the opening brace
$brace_pos = strpos($content, '{', $start_pos);
if ($brace_pos === false) {
return '';
}
$brace_count = 0;
$pos = $brace_pos;
$length = strlen($content);
while ($pos < $length) {
$char = $content[$pos];
if ($char === '{') {
$brace_count++;
} elseif ($char === '}') {
$brace_count--;
if ($brace_count === 0) {
return substr($content, $brace_pos, $pos - $brace_pos + 1);
}
}
$pos++;
}
return '';
}
/**
* Check if a line has an exception comment
*/
private function line_has_exception(array $lines, int $line_num): bool
{
$line_index = $line_num - 1;
// Check current line
if (isset($lines[$line_index]) && str_contains($lines[$line_index], '@' . $this->get_id() . '-EXCEPTION')) {
return true;
}
// Check previous line
if ($line_index > 0 && isset($lines[$line_index - 1]) && str_contains($lines[$line_index - 1], '@' . $this->get_id() . '-EXCEPTION')) {
return true;
}
return false;
}
/**
* Build an extremely clear suggestion about why this is wrong
*/
private function build_suggestion(string $property_name, string $class_name): string
{
$lines = [];
$lines[] = "================================================================================";
$lines[] = "BLACK TAPE OVER THE CHECK ENGINE LIGHT";
$lines[] = "================================================================================";
$lines[] = "";
$lines[] = "This code HIDES network errors by substituting hardcoded fallback data.";
$lines[] = "When the server fails, users see 'working' UI with WRONG DATA.";
$lines[] = "";
$lines[] = "WHAT HAPPENS:";
$lines[] = " 1. Server returns error (500, timeout, network failure)";
$lines[] = " 2. Your code catches the error and assigns fake data";
$lines[] = " 3. User sees UI that looks normal but contains INVENTED values";
$lines[] = " 4. User makes decisions based on WRONG information";
$lines[] = " 5. Error is never noticed, never fixed, never logged properly";
$lines[] = " 6. Application behavior becomes NON-DETERMINISTIC";
$lines[] = "";
$lines[] = "WHY THIS IS CATASTROPHIC:";
$lines[] = " - Enumerated values (roles, statuses, types) MUST have ONE source of truth";
$lines[] = " - Hardcoded fallbacks create SHADOW DATA that diverges from the database";
$lines[] = " - Errors should be VISIBLE so they can be FIXED";
$lines[] = " - Silent failures are WORSE than loud failures";
$lines[] = "";
$lines[] = "WRONG (your code):";
$lines[] = " async on_load() {";
$lines[] = " try {";
$lines[] = " this.data.{$property_name} = await Controller.get_data();";
$lines[] = " } catch (e) {";
$lines[] = " console.error('Failed:', e);";
$lines[] = " this.data.{$property_name} = [{...hardcoded fallback...}]; // DISASTER";
$lines[] = " }";
$lines[] = " }";
$lines[] = "";
$lines[] = "CORRECT:";
$lines[] = " async on_load() {";
$lines[] = " try {";
$lines[] = " this.data.{$property_name} = await Controller.get_data();";
$lines[] = " } catch (e) {";
$lines[] = " this.data.error_data = e; // SURFACE THE ERROR";
$lines[] = " }";
$lines[] = " }";
$lines[] = "";
$lines[] = "Template shows error state when this.data.error_data is set:";
$lines[] = " <% if (this.data.error_data) { %>";
$lines[] = " <Universal_Error_Page_Component \$error_data=this.data.error_data />";
$lines[] = " <% } else { %>";
$lines[] = " ... normal content ...";
$lines[] = " <% } %>";
$lines[] = "";
$lines[] = "================================================================================";
$lines[] = "FIX: Remove the fallback data. Assign this.data.error_data = e instead.";
$lines[] = "================================================================================";
return implode("\n", $lines);
}
}