[ // Keys are relative to base_path() * 'file' => 'rsx/path/to/file.php', // Relative to base_path() * 'hash' => 'sha1_hash_of_file_contents', * 'mtime' => unix_timestamp, * 'size' => bytes, * 'extension' => 'php', * * // PHP-specific fields: * 'namespace' => 'App\\Controllers', * 'class' => 'UserController', * 'fqcn' => 'App\\Controllers\\UserController', * 'extends' => 'BaseController', * 'implements' => ['Interface1', 'Interface2'], * 'traits' => ['TraitName'], * 'attributes' => [ * 'Route' => [['pattern' => '/users', 'methods' => ['GET']]], * 'Cache' => [['ttl' => 3600]] * ], * 'methods' => [ * 'index' => [ * 'name' => 'index', * 'static' => false, * 'visibility' => 'public', * 'attributes' => ['Route' => [['/users', 'GET']]], * 'parameters' => [ * ['name' => 'request', 'type' => 'Request', 'nullable' => false] * ] * ] * ], * 'properties' => [ * ['name' => 'prop', 'visibility' => 'private', 'static' => false] * ], * * // JavaScript-specific fields: * 'imports' => [['from' => 'react', 'imports' => 'React']], * 'exports' => ['ComponentName'], * 'default_export' => 'MainComponent', * 'public_static_methods' => ['getInstance'], * 'static_properties' => ['instance'], * * // View-specific fields: * 'view_id' => 'user-profile', * 'sections' => ['content', 'sidebar'], * 'extends' => 'layouts.main' * ], * // ... more files * ] * * CACHING: * - Stores as PHP array in /storage/rsx-build/manifest_data.php for fast include() * - Also exports as JSON for JavaScript tooling compatibility * - Uses file size + mtime for change detection (fast, avoids unnecessary hashing) */ class Manifest { /** * Debug options for controlling manifest behavior from commands * Set by commands like rsx:manifest:build to control processing */ public static $_debug_options = []; /** * Special directories that are excluded from manifest * @deprecated Use config('rsx.manifest.excluded_dirs') instead */ protected const EXCLUDED_DIRS = ['resource', 'public', 'vendor', 'node_modules', '.git', 'storage']; /** * File extensions to process * @deprecated Use ExtensionRegistry::get_all_extensions() instead */ protected const PROCESSABLE_EXTENSIONS = ['php', 'js', 'jsx', 'ts', 'tsx', 'phtml', 'scss', 'less', 'css', 'blade.php']; /** * Path to the cache file */ protected const CACHE_FILE = '/storage/rsx-build/manifest_data.php'; /** * The loaded manifest data structure: * [ * 'generated' => datetime string, * 'hash' => sha512 hash of data, * 'data' => ['files' => [...file metadata...]] * ] */ protected static ?array $data = null; /** * Whether data has been loaded */ protected static bool $_has_init = false; /** * Flag to signal manifest needs to restart due to file rename */ protected static bool $_needs_manifest_restart = false; // The manifest kernel instance (cached) (???) protected static ?ManifestKernel $kernel = null; protected static $_manifest_compile_lock; protected static $__get_rsx_files_cache; protected static bool $__has_changed_cache; protected static bool $_has_manifest_ready = false; protected static bool $_manifest_is_bad = false; // Track if we've already shown the manifest rescan message this page load protected static bool $__shown_rescan_message = false; // ======================================== // Query Methods // ======================================== /** * Get all manifest data (just the files, not metadata) */ public static function get_all(): array { static::init(); return static::$data['data']['files'] ?? []; } /** * Get the autoloader class map for simplified class name resolution * @return array Map of simple class names to arrays of FQCNs */ public static function get_autoloader_class_map(): array { static::init(); return static::$data['data']['autoloader_class_map'] ?? []; } /** * Get data for a specific file */ public static function get_file(string $file_path): array { static::init(); // Normalize path to forward slashes $file_path = str_replace('\\', '/', $file_path); // Convert to relative path if absolute $base_path_normalized = str_replace('\\', '/', base_path()); if (str_starts_with($file_path, $base_path_normalized)) { $file_path = str_replace($base_path_normalized . '/', '', $file_path); } // Remove leading slash if present $file_path = ltrim($file_path, '/'); if (!isset(static::$data['data']['files'][$file_path])) { throw new \RuntimeException("File not found in manifest: {$file_path}"); } return static::$data['data']['files'][$file_path]; } /** * Find a PHP class by name */ public static function php_find_class(string $class_name): string { static::init(); if (!isset(static::$data['data']['php_classes'][$class_name])) { throw new \RuntimeException("PHP class not found in manifest: {$class_name}"); } return static::$data['data']['php_classes'][$class_name]; } /** * Find a PHP class by fully qualified name */ public static function find_php_fqcn(string $fqcn): string { $files = static::get_all(); foreach ($files as $file => $metadata) { if (isset($metadata['fqcn']) && $metadata['fqcn'] === $fqcn) { return $file; } } throw new \RuntimeException("PHP class with FQCN not found in manifest: {$fqcn}"); } /** * Get manifest metadata by PHP class name * This is a convenience method that finds the class and returns its metadata */ public static function php_get_metadata_by_class(string $class_name): array { $file = static::php_find_class($class_name); return static::get_file($file); } /** * Get manifest metadata by PHP fully qualified class name * This is a convenience method that finds the class and returns its metadata */ public static function php_get_metadata_by_fqcn(string $fqcn): array { $file = static::find_php_fqcn($fqcn); return static::get_file($file); } /** * Find a JavaScript class */ public static function js_find_class(string $class_name): string { static::init(); if (!isset(static::$data['data']['js_classes'][$class_name])) { throw new \RuntimeException("JavaScript class not found in manifest: {$class_name}"); } return static::$data['data']['js_classes'][$class_name]; } /** * Find a view by ID */ public static function find_view(string $id): string { $files = static::get_all(); $matches = []; // Find all files with matching ID (only check Blade views) foreach ($files as $file => $metadata) { if (isset($metadata['id']) && $metadata['id'] === $id && str_ends_with($file, '.blade.php')) { $matches[] = $file; } } // Check results if (count($matches) === 0) { throw new \RuntimeException("View not found in manifest: {$id}"); } if (count($matches) > 1) { $file_list = implode("\n - ", $matches); throw new \RuntimeException( "Duplicate view ID detected: {$id}\n" . "Found in multiple files:\n - {$file_list}\n" . 'View IDs must be unique across all Blade files.' ); } return $matches[0]; } /** * Find a view by RSX ID (path-agnostic identifier) */ public static function find_view_by_rsx_id(string $id): string { // This method now properly checks for duplicates return static::find_view($id); } /** * Get path for a file by its filename only (quick and dirty lookup) * * This is a convenience method for finding files when you know the filename is unique. * Only works for files in the /rsx directory. Fatal errors if: * - File not found in manifest * - Multiple files with the same name exist * - File is outside /rsx directory * * @param string $filename Just the filename with extension (e.g., "Counter_Widget.jqhtml") * @return string The relative path to the file (e.g., "rsx/app/demo/components/Counter_Widget.jqhtml") * @throws RuntimeException If file not found, multiple matches, or outside /rsx */ public static function get_path_by_filename(string $filename): string { $files = static::get_all(); $matches = []; foreach ($files as $path => $metadata) { // Only consider files in /rsx directory if (!str_starts_with($path, 'rsx/')) { continue; } // Extract just the filename from the path $file_basename = basename($path); if ($file_basename === $filename) { $matches[] = $path; } } if (empty($matches)) { throw new \RuntimeException( "Fatal: File not found in manifest: {$filename}\n" . 'This method only searches files in the /rsx directory.' ); } if (count($matches) > 1) { throw new \RuntimeException( "Fatal: Multiple files with name '{$filename}' found in manifest:\n" . ' - ' . implode("\n - ", $matches) . "\n" . 'This method requires unique filenames.' ); } return $matches[0]; } /** * Get all classes extending a parent (filters out abstract classes by default) * Returns array of class metadata indexed by class name */ public static function php_get_extending(string $parentclass): array { // Get concrete subclasses only (abstract filtered out by default) $subclasses = self::php_get_subclasses_of($parentclass, true); $classpile = []; foreach ($subclasses as $classname) { // Get the file path from php_classes index, then get metadata from files if (isset(static::$data['data']['php_classes'][$classname])) { $file_path = static::$data['data']['php_classes'][$classname]; $classpile[$classname] = static::$data['data']['files'][$file_path]; } } return $classpile; } /** * Get all JavaScript classes extending a parent * Returns array of class metadata indexed by class name */ public static function js_get_extending(string $parentclass): array { // Get all subclasses (JavaScript has no abstract concept) $subclasses = self::js_get_subclasses_of($parentclass); $classpile = []; foreach ($subclasses as $classname) { // Get the file path from js_classes index, then get metadata from files if (isset(static::$data['data']['js_classes'][$classname])) { $file_path = static::$data['data']['js_classes'][$classname]; $classpile[$classname] = static::$data['data']['files'][$file_path]; } } return $classpile; } /** * Check if a class is a subclass of another by traversing the inheritance chain * * @param string $subclass The child class name (simple name, not FQCN) * @param string $superclass The parent class name to check for (simple name, not FQCN) * @return bool True if subclass extends superclass (directly or indirectly), false otherwise */ public static function php_is_subclass_of(string $subclass, string $superclass): bool { // Strip namespace if FQCN was passed (contains backslash) if (strpos($subclass, '\\') !== false) { // Get the class name after the last backslash $parts = explode('\\', $subclass); $subclass = end($parts); } if (strpos($superclass, '\\') !== false) { // Get the class name after the last backslash $parts = explode('\\', $superclass); $superclass = end($parts); } $files = static::get_all(); $current_class = $subclass; $visited = []; // Prevent infinite loops in case of circular inheritance while ($current_class) { // Prevent infinite loops if (in_array($current_class, $visited)) { return false; } $visited[] = $current_class; // Find the current class in the manifest if (!isset(static::$data['data']['php_classes'][$current_class])) { return false; } // Get file metadata $file_path = static::$data['data']['php_classes'][$current_class]; $metadata = static::$data['data']['files'][$file_path]; if (empty($metadata['extends'])) { return false; } if ($metadata['extends'] == $superclass) { return true; } // TODO: Maybe use native reflection if base class does not exist in the manifest past this point> // Move up the chain to the parent class $current_class = $metadata['extends']; } return false; } /** * Check if a PHP class is abstract * * @param string $class_name The class name to check (simple name, not FQCN) * @return bool True if the class is abstract, false if concrete or not found */ public static function php_is_abstract(string $class_name): bool { // Ensure manifest is loaded static::init(); // Strip namespace if FQCN was passed if (strpos($class_name, '\\') !== false) { $parts = explode('\\', $class_name); $class_name = end($parts); } // Return false if class not in manifest if (!isset(static::$data['data']['php_classes'][$class_name])) { return false; } // Get file metadata and check the abstract property $file_path = static::$data['data']['php_classes'][$class_name]; $metadata = static::$data['data']['files'][$file_path]; return $metadata['abstract'] ?? false; } /** * Get the full inheritance lineage (ancestry) of a PHP class * * Returns an array of parent class names from immediate parent to top-level ancestor. * Example: For class C extends B extends A, returns ['B', 'A'] * * @param string $class_name The class name (FQCN or simple name) * @return array Array of parent class simple names in order from immediate parent to root */ public static function php_get_lineage(string $class_name): array { // Ensure manifest is loaded static::init(); // Strip namespace if FQCN was passed if (strpos($class_name, '\\') !== false) { $parts = explode('\\', $class_name); $class_name = end($parts); } $lineage = []; $current_class = $class_name; $visited = []; // Prevent infinite loops while ($current_class) { // Prevent infinite loops in circular inheritance if (in_array($current_class, $visited)) { break; } $visited[] = $current_class; // Find current class in manifest if (!isset(static::$data['data']['php_classes'][$current_class])) { break; } $file_path = static::$data['data']['php_classes'][$current_class]; $metadata = static::$data['data']['files'][$file_path]; if (!$metadata) { break; } $extends = $metadata['extends'] ?? null; if (!$extends) { break; } // Add parent to lineage (simple name) $lineage[] = $extends; // Move up the chain $current_class = $extends; } return $lineage; } /** * Check if a class is a subclass of another by traversing the inheritance chain * * @param string $subclass The child class name (simple name, not FQCN) * @param string $superclass The parent class name to check for (simple name, not FQCN) * @return bool True if subclass extends superclass (directly or indirectly), false otherwise */ public static function js_is_subclass_of(string $subclass, string $superclass): bool { // Strip namespace if FQCN was passed (contains backslash) if (strpos($subclass, '\\') !== false) { // Get the class name after the last backslash $parts = explode('\\', $subclass); $subclass = end($parts); } if (strpos($superclass, '\\') !== false) { // Get the class name after the last backslash $parts = explode('\\', $superclass); $superclass = end($parts); } $files = static::get_all(); $current_class = $subclass; $visited = []; // Prevent infinite loops in case of circular inheritance while ($current_class) { // Prevent infinite loops if (in_array($current_class, $visited)) { return false; } $visited[] = $current_class; // Find the current class in the manifest if (!isset(static::$data['data']['js_classes'][$current_class])) { return false; } // Get file metadata $file_path = static::$data['data']['js_classes'][$current_class]; $metadata = static::$data['data']['files'][$file_path]; if (empty($metadata['extends'])) { return false; } if ($metadata['extends'] == $superclass) { return true; } // Move up the chain to the parent class $current_class = $metadata['extends']; } return false; } /** * Get the complete inheritance chain for a JavaScript class * Returns array of parent class names in order from immediate parent to root * * @param string $class_name The class name to get lineage for * @return array Array of parent class names (empty if class not found or has no parents) * Example: If A extends B and B extends C, js_get_lineage('A') returns ['B', 'C'] */ public static function js_get_lineage(string $class_name): array { // Strip namespace if FQCN was passed if (strpos($class_name, '\\') !== false) { $parts = explode('\\', $class_name); $class_name = end($parts); } $lineage = []; $current_class = $class_name; $visited = []; // Prevent infinite loops while ($current_class) { // Prevent infinite loops in circular inheritance if (in_array($current_class, $visited)) { break; } $visited[] = $current_class; // Find the current class in manifest if (!isset(static::$data['data']['js_classes'][$current_class])) { break; } // Get the file path from js_classes index, then get metadata from files $file_path = static::$data['data']['js_classes'][$current_class]; $metadata = static::$data['data']['files'][$file_path]; $extends = $metadata['extends'] ?? null; if (!$extends) { break; } // Add parent to lineage $lineage[] = $extends; // Move up the chain $current_class = $extends; } return $lineage; } /** * Get all direct subclasses of a given PHP class using the pre-built index * * @param string $class_name The parent class name (simple name, not FQCN) * @param bool $concrete_only Whether to filter out abstract classes (default: true) * @return array Array of subclass names, or empty array if class not found or has no children */ public static function php_get_subclasses_of(string $class_name, bool $concrete_only = true): array { // Strip namespace if FQCN was passed if (strpos($class_name, '\\') !== false) { $parts = explode('\\', $class_name); $class_name = end($parts); } // Return empty array if class not in subclass_index if (!isset(static::$data['data']['php_subclass_index'][$class_name])) { return []; } $subclasses = static::$data['data']['php_subclass_index'][$class_name]; // If not filtering for concrete classes, return all subclasses if (!$concrete_only) { return $subclasses; } // Filter out abstract classes $concrete_subclasses = []; foreach ($subclasses as $subclass) { // Get file path and metadata if (!isset(static::$data['data']['php_classes'][$subclass])) { shouldnt_happen( "Fatal: PHP class '{$subclass}' found in subclass index but not in php_classes.\n" . "This indicates a major data integrity issue with the manifest.\n" . 'Try running: php artisan rsx:manifest:build --clean' ); } $file_path = static::$data['data']['php_classes'][$subclass]; $metadata = static::$data['data']['files'][$file_path]; // Check if abstract property exists in manifest data if (!isset($metadata['abstract'])) { shouldnt_happen( "Fatal: Abstract property missing for PHP class '{$subclass}' in manifest data.\n" . "This indicates a major data integrity issue with the manifest.\n" . 'Try running: php artisan rsx:manifest:build --clean' ); } // Include only non-abstract classes if (!$metadata['abstract']) { $concrete_subclasses[] = $subclass; } } return $concrete_subclasses; } /** * Get all direct subclasses of a given JavaScript class using the pre-built index * * @param string $class_name The parent class name * @return array Array of subclass names, or empty array if class not found or has no children */ public static function js_get_subclasses_of(string $class_name): array { // Return empty array if class not in subclass_index if (!isset(static::$data['data']['js_subclass_index'][$class_name])) { return []; } return static::$data['data']['js_subclass_index'][$class_name]; } /** * Get all classes with a specific attribute */ public static function get_with_attribute(string $attribute_class): array { $files = static::get_all(); $results = []; foreach ($files as $file => $metadata) { // Check class attributes if (isset($metadata['attributes'][$attribute_class])) { $results[] = [ 'file' => $file, 'class' => $metadata['class'] ?? null, 'fqcn' => $metadata['fqcn'] ?? null, 'type' => 'class', 'instances' => $metadata['attributes'][$attribute_class], ]; } // Check public static method attributes (PHP files) if (isset($metadata['public_static_methods'])) { foreach ($metadata['public_static_methods'] as $method_name => $method_data) { if (isset($method_data['attributes'][$attribute_class])) { $results[] = [ 'file' => $file, 'class' => $metadata['class'] ?? null, 'fqcn' => $metadata['fqcn'] ?? null, 'method' => $method_name, 'type' => 'method', 'instances' => $method_data['attributes'][$attribute_class], ]; } } } // Check regular method attributes (JS files may have these) if (isset($metadata['methods'])) { foreach ($metadata['methods'] as $method_name => $method_data) { if (isset($method_data['attributes'][$attribute_class])) { $results[] = [ 'file' => $file, 'class' => $metadata['class'] ?? null, 'fqcn' => $metadata['fqcn'] ?? null, 'method' => $method_name, 'type' => 'method', 'instances' => $method_data['attributes'][$attribute_class], ]; } } } } // Sort alphabetically by class name to ensure deterministic behavior and prevent race condition bugs usort($results, function ($a, $b) { return strcmp($a['class'] ?? '', $b['class'] ?? ''); }); return $results; } /** * Get all routes from the manifest */ public static function get_routes(): array { static::init(); $routes = [ 'controllers' => [], 'api' => [], ]; // Look for Route attributes - must check all namespaces since Route is not a real class // PHP attributes without an import will use the current namespace $files = static::get_all(); $route_classes = []; foreach ($files as $file => $metadata) { // Check public static method attributes for any attribute ending with 'Route' if (isset($metadata['public_static_methods'])) { foreach ($metadata['public_static_methods'] as $method_name => $method_data) { if (isset($method_data['attributes'])) { foreach ($method_data['attributes'] as $attr_name => $attr_instances) { // Check if this is a Route attribute (ends with \Route or is just Route) if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') { $route_classes[] = [ 'file' => $file, 'class' => $metadata['class'] ?? null, 'fqcn' => $metadata['fqcn'] ?? null, 'method' => $method_name, 'type' => 'method', 'instances' => $attr_instances, ]; } } } } } } foreach ($route_classes as $item) { if ($item['type'] === 'method') { foreach ($item['instances'] as $route_args) { $pattern = $route_args[0] ?? ($route_args['pattern'] ?? null); $methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']); $name = $route_args[2] ?? ($route_args['name'] ?? null); if ($pattern) { // Ensure pattern starts with / if ($pattern[0] !== '/') { $pattern = '/' . $pattern; } // Determine type (API or controller) $type = str_contains($item['file'], '/api/') || str_contains($item['class'] ?? '', 'Api') ? 'api' : 'controllers'; // Initialize route if not exists if (!isset($routes[$type][$pattern])) { $routes[$type][$pattern] = []; } // Extract Auth attributes for this method from the file metadata $require_attrs = []; $file_metadata = $files[$item['file']] ?? null; if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) { $require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth']; } // Add for each HTTP method foreach ((array) $methods as $method) { $method_upper = strtoupper($method); // Initialize method array if not exists if (!isset($routes[$type][$pattern][$method_upper])) { $routes[$type][$pattern][$method_upper] = []; } // Add handler to array (allows duplicates for dispatch-time detection) $routes[$type][$pattern][$method_upper][] = [ 'class' => $item['fqcn'] ?? $item['class'], 'method' => $item['method'], 'name' => $name, 'file' => $item['file'], 'require' => $require_attrs, ]; } } } } } // Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs ksort($routes['controllers']); ksort($routes['api']); return $routes; } /** * Get Auth attributes from a controller's pre_dispatch method * * @param string $class_name Simple class name or FQCN * @return array Array of Auth attribute instances */ public static function get_pre_dispatch_requires(string $class_name): array { static::init(); try { // Try to get metadata by class name or FQCN if (strpos($class_name, '\\') !== false) { $metadata = static::php_get_metadata_by_fqcn($class_name); } else { $metadata = static::php_get_metadata_by_class($class_name); } // Check if pre_dispatch method exists and has Auth attributes if (isset($metadata['public_static_methods']['pre_dispatch']['attributes']['Auth'])) { return $metadata['public_static_methods']['pre_dispatch']['attributes']['Auth']; } return []; } catch (\RuntimeException $e) { // Class not found in manifest return []; } } /** * Get statistics about the manifest */ public static function get_stats(): array { static::init(); $files = static::get_all(); $stats = [ 'total_files' => count($files), 'php' => 0, 'js' => 0, 'blade' => 0, 'scss' => 0, 'css' => 0, 'other' => 0, 'classes' => 0, 'routes' => 0, ]; foreach ($files as $file => $metadata) { $ext = $metadata['extension'] ?? ''; switch ($ext) { case 'php': $stats['php']++; break; case 'blade.php': $stats['blade']++; break; case 'js': case 'jsx': case 'ts': case 'tsx': $stats['js']++; break; case 'scss': case 'less': $stats['scss']++; break; case 'css': $stats['css']++; break; default: $stats['other']++; } if (isset($metadata['class'])) { $stats['classes']++; } } $routes = static::get_routes(); $stats['routes'] = count($routes['controllers']) + count($routes['api']); return $stats; } /** * Check if manifest is built */ public static function is_built(): bool { return file_exists(static::__get_cache_file_path()); } /** * Get files from manifest by directory path * * @param string $directory Directory path without wildcards (e.g., 'rsx/ui') * @return array Array of manifest entries for files in the directory */ public static function get_files_by_dir(string $directory): array { static::init(); $files = []; // Normalize directory to forward slashes $directory = str_replace('\\', '/', $directory); $directory = rtrim($directory, '/'); // Remove trailing slash if present // Check if we have cached data if (empty(static::$data['data']['files'])) { return $files; } // Iterate through all files in the manifest foreach (static::$data['data']['files'] as $file_path => $file_data) { // Normalize the file path to use forward slashes $normalized_path = str_replace('\\', '/', $file_path); // Check if the file is in the specified directory if (str_starts_with($normalized_path, $directory . '/')) { $files[$file_path] = $file_data; } } // Sort alphabetically by filename to ensure deterministic behavior and prevent race condition bugs ksort($files); return $files; } /** * Get the full manifest structure including metadata * Used for debugging and manifest:dump command * Returns by reference to avoid copying large array */ public static function &get_full_manifest(): array { static::init(); return static::$data; } /** * TODO: UPDATE DOCUMENTATION * Initialize and ensure manifest is loaded * Handles all loading/rebuilding logic internally: * - If already initialized, returns immediately * - If cache exists, loads it and updates only changed files * - If no cache exists, processes all files (equivalent to rebuild) */ public static function init(): void { // Acquire application lock before any manifest operations // This ensures proper concurrency control for all processes // Already initialized, nothing to do if (static::$_has_init) { return; } static::$_has_init = true; // Gets application concurrency lock, does other not manifest init steps // Todo: review the process - should this happen here? Maybe this is all better suited in rspade provider? \App\RSpade\Core\Bootstrap\RsxBootstrap::initialize(); // Scan handles both incremental updates and full rebuilds // Load cached data if it exists $cache_file_path = self::__get_cache_file_path(); console_debug('MANIFEST', 'Checking for manifest cache', $cache_file_path); $loaded_cache = self::__load_cached_data(); if ($loaded_cache && env('APP_ENV') == 'production') { // In prod mode, if cache loaded, assume the cache is good, we are done with it console_debug('MANIFEST', 'Manifest cache loaded successfully (production mode)'); self::$_has_manifest_ready = true; \App\RSpade\Core\Autoloader::register(); return; } if ($loaded_cache) { console_debug('MANIFEST', 'Manifest cache loaded successfully (development mode), validating...'); if (self::__validate_cached_data()) { console_debug('MANIFEST', 'Manifest is valid'); self::$_has_manifest_ready = true; \App\RSpade\Core\Autoloader::register(); return; } console_debug('MANIFEST', 'Manifest is out of date'); } else { console_debug('MANIFEST', 'Manifest could not be loaded'); } console_debug('MANIFEST', 'Aquiring manifest build lock'); // Get a manifest build lock self::$_manifest_compile_lock = RsxLocks::get_lock( RsxLocks::SERVER_LOCK, RsxLocks::LOCK_MANIFEST_BUILD, RsxLocks::WRITE_LOCK, config('rsx.locking.timeout', 30) ); console_debug('MANIFEST', 'Manifest build lock acquired, checking to see if manifest cache was updated'); // Maybe the manifest was regenerated again? double check now that we are exclusive $cache_loaded = self::__load_cached_data(); $cache_valid = self::__validate_cached_data(); if (!$cache_valid) { // Log only for full rebuilds (cache doesn't exist) // Incremental updates are normal and don't need logging $cache_file = self::__get_cache_file_path(); if (!$cache_loaded) { console_debug_force('MANIFEST', 'Manifest cache does not exist, performing full rebuild', $cache_file); } // zug zug self::_refresh_manifest(); // jobs done console_debug('MANIFEST', 'Refreshing manifest *completed*'); // Verify cache was written successfully if (file_exists($cache_file)) { $file_size = filesize($cache_file); $file_perms = substr(sprintf('%o', fileperms($cache_file)), -4); console_debug('MANIFEST', 'Cache file written successfully', [ 'path' => $cache_file, 'size' => $file_size, 'permissions' => $file_perms ]); } else { console_debug_force('MANIFEST', 'WARNING: Cache file does not exist after rebuild!', $cache_file); } } else { console_debug('MANIFEST', 'Manifest cache is valid, no rebuild needed'); } RsxLocks::release_lock(self::$_manifest_compile_lock); console_debug('MANIFEST', 'Released manifest build lock'); self::$_has_manifest_ready = true; \App\RSpade\Core\Autoloader::register(); } /** * Clear the manifest cache */ public static function clear(): void { static::$data = [ 'generated' => date('Y-m-d H:i:s'), 'hash' => '', 'data' => [ 'files' => [], 'autoloader_class_map' => [], ], ]; static::$_has_init = false; static::$_has_manifest_ready = false; $cache_file = static::__get_cache_file_path(); if (file_exists($cache_file)) { unlink($cache_file); } } /** * Unlink the manifest cache file only (fast rebuild trigger) * * This removes only the manifest cache file, preserving all parsed AST data * and incremental caches. On next load, the manifest will do a full scan and * reindex but will reuse existing parsed metadata where files haven't changed. * * This is much faster than rsx:clean which wipes all caches including parsed * AST data, forcing expensive re-parsing of all PHP/JS files. * * Use this after database migrations or schema changes that affect model * metadata without changing the actual source code. */ public static function _unlink_cache(): void { $cache_file = static::__get_cache_file_path(); if (file_exists($cache_file)) { @unlink($cache_file); } } /** * Signal that manifest needs to restart due to file rename * Called by code quality rules when auto-renaming files */ public static function flag_needs_restart(): void { static::$_needs_manifest_restart = true; } /** * Normalize class name to simple name (strip namespace qualifiers) * * Since RSX enforces unique simple class names across the codebase, * we normalize all class references to simple names for consistent * comparison and storage. FQCNs are only needed at actual class loading time. * * Examples: * \Rsx\Lib\DataGrid → DataGrid * Rsx\Lib\DataGrid → DataGrid * DataGrid → DataGrid * * @param string $class_name Class name in any format (with or without namespace) * @return string Simple class name without namespace */ public static function _normalize_class_name(string $class_name): string { // Strip leading backslash $class_name = ltrim($class_name, '\\'); // Extract just the class name (last part after final backslash) $parts = explode('\\', $class_name); return end($parts); } /** * Get the build key for cache prefixing * This is the manifest hash that uniquely identifies the current code state * * IMPORTANT: This will throw a fatal error if called before the manifest is loaded * The manifest must have completed loading (either from cache or scan) before this is available * * @return string The manifest build key (hash) * @throws RuntimeException if manifest is not yet loaded */ public static function get_build_key(): string { // Check if manifest has been loaded if (!static::$_has_manifest_ready) { shouldnt_happen('Manifest::get_build_key() called before manifest was loaded. The manifest must complete loading before the build key is available.'); } // Also verify we actually have a hash if (empty(static::$data['hash'])) { shouldnt_happen('Manifest is loaded but has no build hash. This should not happen.'); } return static::$data['hash']; } // ------------------------------------------------------------------------ // ---- RSpade Public Internal Methods: // ------------------------------------------------------------------------ public static function _refresh_manifest() { manifest_start: // Reset manifest restart flag at the beginning of each pass static::$_needs_manifest_restart = false; // Reset manifest structure, retaining only existing files data $existing_files = static::$data['data']['files'] ?? []; static::$data = [ 'generated' => date('Y-m-d H:i:s'), 'hash' => '', 'data' => [ 'files' => $existing_files, 'autoloader_class_map' => [], ], ]; // ======================================================= // Phase 1: Collect all files in manifest scan directories // ======================================================= $files = static::__get_rsx_files(); $changes = false; // Check if any files have changed $files_to_process = []; foreach ($files as $file) { if (static::__has_changed($file)) { $files_to_process[] = $file; $changes = true; } } console_debug('MANIFEST', 'Phase 1: File Discovery - ' . count($files) . ' files, ' . count($files_to_process) . ' changed'); // If any files have changed and we're not in production, run auto-reformat if ($changes && env('APP_ENV') !== 'production') { $formatter_path = base_path('bin/rsx-format'); // Lets rethink this before we enable iut again // if (file_exists($formatter_path)) { // // Run the formatter with the hidden --auto-reformat-periodic flag // // This ensures formatting happens BEFORE any RSX file is loaded/parsed // $command = escapeshellcmd($formatter_path) . ' --auto-reformat-periodic 2>&1'; // \exec_safe($command, $output, $return_code); // // Only log errors, not normal operation // if ($return_code !== 0) { // error_log('RSX auto-reformat-periodic failed: ' . implode("\n", $output)); // } // } } // ======================================================= // Phase 2: Parse Metadata - Extract basic metadata via token parsing // ======================================================= console_debug('MANIFEST', 'Phase 2: Parse Metadata - Processing ' . count($files_to_process) . ' files'); // Filter out storage files from the manifest static::$data['data']['files'] = array_filter( static::$data['data']['files'], function ($key) { return !str_starts_with($key, 'storage/'); }, ARRAY_FILTER_USE_KEY ); // Remove deleted files from manifest BEFORE processing $existing_files = array_flip($files); foreach (array_keys(static::$data['data']['files']) as $cached_file) { if (!isset($existing_files[$cached_file])) { unset(static::$data['data']['files'][$cached_file]); $changes = true; } } foreach ($files_to_process as $file) { static::$data['data']['files'][$file] = static::__process_file($file); } // Skip validation message if no changes detected if (!$changes && file_exists(static::__get_cache_file_path())) { // This case shouldn't happen as it should have been caught earlier // but we don't need to log it as it's not an error condition } // Validate class names are unique. static::__check_unique_base_class_names(); // Apply Php_Fixer to all PHP files in rsx/ and app/RSpade/ before parsing $php_fixer_modified_files = []; if (!app()->environment('production')) { $php_fixer_modified_files = static::__run_php_fixer($files_to_process); // Re-parse files that Php_Fixer modified to update manifest with corrected metadata // This ensures namespace/class/fqcn data matches what's actually in the file // CRITICAL: Without this, we'd have stale FQCNs from before Php_Fixer ran if (!empty($php_fixer_modified_files)) { console_debug('MANIFEST', 'Re-parsing ' . count($php_fixer_modified_files) . ' files modified by Php_Fixer'); foreach ($php_fixer_modified_files as $file_path) { // Re-extract metadata with corrected namespace $absolute_path = base_path($file_path); $php_metadata = \App\RSpade\Core\PHP\Php_Parser::parse($absolute_path, static::$data); // Update manifest with corrected metadata static::$data['data']['files'][$file_path] = array_merge( static::$data['data']['files'][$file_path], $php_metadata ); // Recalculate file hash since file was modified clearstatcache(true, $absolute_path); $updated_stat = stat($absolute_path); static::$data['data']['files'][$file_path]['hash'] = sha1_file($absolute_path); static::$data['data']['files'][$file_path]['mtime'] = $updated_stat['mtime']; static::$data['data']['files'][$file_path]['size'] = $updated_stat['size']; } } } // Phase 2 complete. At this point we have a list of all files, and for php and js, their class data // ======================================================= // Phase 3: Load Dependencies - Load PHP files in dependency order // ======================================================= console_debug('MANIFEST', 'Phase 3: Load Dependencies'); // Only load PHP files that have actually changed static::__load_changed_php_files($files_to_process); // Process code quality rule metadata extraction static::__process_code_quality_metadata($files_to_process); // ======================================================= // Phase 4: Extract Reflection - Extract reflection data from PHP classes // ======================================================= console_debug('MANIFEST', 'Phase 4: Extract Reflection'); // Extract reflection data for changed PHP files only static::__extract_reflection_for_changed_files($files_to_process); // Collate files by classes - MUST be called after reflection extraction // so that abstract property is available for subclass filtering static::__collate_files_by_classes(); // ======================================================= // Phase 5: Process Modules - Run manifest support modules and build autoloader // ======================================================= console_debug('MANIFEST', 'Phase 5: Process Modules'); // Build autoloader class map // The major thing that happens here is this also scans app/RSpade for additional classes which arent on manifest, // which is a somewhat expensive operation (50 ms). This is acceptable for a incremental manifest rebuild static::$data['data']['autoloader_class_map'] = static::__build_autoloader_class_map(); // Process manifest support modules $support_modules = config('rsx.manifest_support', []); foreach ($support_modules as $support_module_class) { if (!class_exists($support_module_class)) { throw new \RuntimeException("Manifest support module class not found: {$support_module_class}"); } if (!self::php_is_subclass_of($support_module_class, ManifestSupport_Abstract::class)) { throw new \RuntimeException("Manifest support module must extend ManifestSupport_Abstract: {$support_module_class}"); } if ($support_module_class::should_run()) { $support_module_class::process(static::$data); } } // Note: Validation checks have been moved to code quality rules that run at manifest-time // ======================================================= // Phase 6: Generate Stubs - Generate JavaScript API and model stubs // ======================================================= console_debug('MANIFEST', 'Phase 6: Generate Stubs'); // Call generate_manifest_stubs on all registered integrations foreach (IntegrationRegistry::get_all() as $integration_class) { if (method_exists($integration_class, 'generate_manifest_stubs')) { $integration_class::generate_manifest_stubs(static::$data); } } // ======================================================= // Phase 7: Save & Finalize - Save manifest, clear caches, run checks // ======================================================= $php_class_count = count(static::$data['data']['php_classes'] ?? []); $js_class_count = count(static::$data['data']['js_classes'] ?? []); console_debug('MANIFEST', 'Phase 7: Saving manifest (' . count($files) . " files, {$php_class_count} PHP classes, {$js_class_count} JS classes)"); static::__generate_vscode_stubs(); static::__save(); // Clear view cache when manifest changes to prevent stale @rsx_extends references // This ensures that renamed blade files with @rsx_extends are properly recompiled \Illuminate\Support\Facades\Artisan::call('view:clear', [], new \Symfony\Component\Console\Output\NullOutput()); // Run manifest-time code quality checks (development only) if (env('APP_ENV') !== 'production' && $changes) { static::__run_manifest_time_code_quality_checks(); } // Check if a file was auto-renamed and manifest needs to restart if (static::$_needs_manifest_restart) { console_debug('MANIFEST', 'File auto-renamed during code quality check, restarting manifest build'); goto manifest_start; } } /** * Load a class and all its parent classes from manifest data * * This utility method ensures a class and its entire parent hierarchy * are loaded before doing reflection or other operations. * Classes are loaded in dependency order (parents first). * Used by stub generators and reflection extraction. * * @param string $fqcn Fully qualified class name to load * @param array $manifest_data The manifest data array * @return void * @throws \RuntimeException if class or parent cannot be loaded */ public static function _load_class_hierarchy(string $fqcn, array $manifest_data): void { // Already loaded? Nothing to do if (class_exists($fqcn, false) || interface_exists($fqcn, false) || trait_exists($fqcn, false)) { return; } // Build list of classes to load in hierarchy order $hierarchy = []; $current_fqcn = $fqcn; while ($current_fqcn) { // Find this class in the manifest $found = false; foreach ($manifest_data['data']['files'] as $file_path => $metadata) { if (isset($metadata['fqcn']) && $metadata['fqcn'] === $current_fqcn) { // Add to front of hierarchy (parents first) array_unshift($hierarchy, [ 'fqcn' => $current_fqcn, 'file' => $file_path, 'extends' => $metadata['extends'] ?? null, ]); $found = true; // Move to parent class // extends is always stored as simple class name (normalized by parser) if (isset($metadata['extends'])) { $parent_simple_name = static::_normalize_class_name($metadata['extends']); // Look for this class by simple name in manifest // Only check PHP files (those with fqcn key) $parent_fqcn = null; foreach ($manifest_data['data']['files'] as $parent_file => $parent_meta) { if (isset($parent_meta['class']) && $parent_meta['class'] === $parent_simple_name && isset($parent_meta['fqcn'])) { $parent_fqcn = $parent_meta['fqcn']; break; } } $current_fqcn = $parent_fqcn; } else { $current_fqcn = null; } break; } } if (!$found && $current_fqcn) { // Check if it's a built-in or framework class that can be autoloaded // Try to autoload it if (class_exists($current_fqcn, true) || interface_exists($current_fqcn, true) || trait_exists($current_fqcn, true)) { // Framework or built-in class, stop here break; } // If still not found, it's a fatal error shouldnt_happen("Parent class {$current_fqcn} not found in manifest or autoloader for {$fqcn}"); } } // Load classes in order (parents first) foreach ($hierarchy as $class_info) { if (!class_exists($class_info['fqcn'], false) && !interface_exists($class_info['fqcn'], false) && !trait_exists($class_info['fqcn'], false)) { $full_path = base_path($class_info['file']); if (!file_exists($full_path)) { shouldnt_happen("Class file not found: {$full_path} for {$class_info['fqcn']}"); } // This includes the file. // A side effect of this include is this line also lints the file. Past this point, we can assume all php // files (well, class files) have valid syntax. include_once $full_path; // Verify the class loaded successfully if (!class_exists($class_info['fqcn'], false) && !interface_exists($class_info['fqcn'], false) && !trait_exists($class_info['fqcn'], false)) { shouldnt_happen("Failed to load class {$class_info['fqcn']} from {$full_path}"); } } } } /** * Mark manifest as bad to force rebuild on next load */ public static function _set_manifest_is_bad(): void { static::$_manifest_is_bad = true; static::__save(); } // ------------------------------------------------------------------------ // ---- Private / Protected Methods: // ------------------------------------------------------------------------ /** * Generate JavaScript stub files for PHP controllers with Internal API methods * These stubs enable IDE autocomplete for Ajax.call() invocations from JavaScript */ protected static function _generate_js_api_stubs(): void { $stub_dir = storage_path('rsx-build/js-stubs'); // Create directory if it doesn't exist if (!is_dir($stub_dir)) { mkdir($stub_dir, 0755, true); } // Track generated stub files for cleanup $generated_stubs = []; // Process each file foreach (static::$data['data']['files'] as $file_path => &$metadata) { // Skip non-PHP files if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { continue; } // Skip files without classes or public static methods if (!isset($metadata['class']) || !isset($metadata['public_static_methods'])) { continue; } // Check if this is a controller (extends Rsx_Controller_Abstract) $class_name = $metadata['class'] ?? ''; if (!static::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) { continue; } // Check if this controller has any Ajax_Endpoint methods $api_methods = []; foreach ($metadata['public_static_methods'] as $method_name => $method_info) { if (!isset($method_info['attributes'])) { continue; } // Check for Ajax_Endpoint attribute $has_api_internal = false; foreach ($method_info['attributes'] as $attr_name => $attr_data) { if ($attr_name === 'Ajax_Endpoint' || basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') { $has_api_internal = true; break; } } if ($has_api_internal && isset($method_info['static']) && $method_info['static']) { $api_methods[$method_name] = [ 'name' => $method_name, ]; } } // If no API methods, remove js_stub property if it exists and skip if (empty($api_methods)) { if (isset($metadata['js_stub'])) { unset($metadata['js_stub']); } continue; } // Generate stub filename and paths $controller_name = $metadata['class']; $stub_filename = static::_sanitize_stub_filename($controller_name) . '.js'; $stub_relative_path = 'storage/rsx-build/js-stubs/' . $stub_filename; $stub_full_path = base_path($stub_relative_path); // Check if stub needs regeneration $needs_regeneration = true; if (file_exists($stub_full_path)) { // Get mtime of source PHP file $source_mtime = $metadata['mtime'] ?? 0; $stub_mtime = filemtime($stub_full_path); // Only regenerate if source is newer than stub if ($stub_mtime >= $source_mtime) { // Also check if the API methods signature has changed // by comparing a hash of the methods $api_methods_hash = md5(json_encode($api_methods)); $old_api_hash = $metadata['api_methods_hash'] ?? ''; if ($api_methods_hash === $old_api_hash) { $needs_regeneration = false; } } } // Store the API methods hash for future comparisons $metadata['api_methods_hash'] = md5(json_encode($api_methods)); if ($needs_regeneration) { // Generate stub content $stub_content = static::_generate_stub_content($controller_name, $api_methods); // Write stub file file_put_contents($stub_full_path, $stub_content); } $generated_stubs[] = $stub_filename; // Add js_stub property to manifest data (relative path) $metadata['js_stub'] = $stub_relative_path; // Add the stub file itself to the manifest // This is critical because storage/rsx-build/js-stubs is not in scan directories $stat = stat($stub_full_path); static::$data['data']['files'][$stub_relative_path] = [ 'file' => $stub_relative_path, 'hash' => sha1_file($stub_full_path), 'mtime' => $stat['mtime'], 'size' => $stat['size'], 'extension' => 'js', 'class' => $controller_name, 'is_stub' => true, // Mark this as a generated stub 'source_controller' => $file_path, // Reference to the source controller ]; } // Clean up orphaned stub files (both from disk and manifest) $existing_stubs = glob($stub_dir . '/*.js'); foreach ($existing_stubs as $existing_stub) { $filename = basename($existing_stub); if (!in_array($filename, $generated_stubs)) { // Remove from disk unlink($existing_stub); // Remove from manifest $stub_relative_path = 'storage/rsx-build/js-stubs/' . $filename; if (isset(static::$data['data']['files'][$stub_relative_path])) { unset(static::$data['data']['files'][$stub_relative_path]); } } } } /** * Generate JavaScript stub files for ORM models * These stubs enable IDE autocomplete and provide relationship methods */ protected static function _generate_js_model_stubs(): void { $stub_dir = storage_path('rsx-build/js-model-stubs'); // Create directory if it doesn't exist if (!is_dir($stub_dir)) { mkdir($stub_dir, 0755, true); } // Track generated stub files for cleanup $generated_stubs = []; // Get all models from the manifest $model_entries = static::get_extending('Rsx_Model_Abstract'); foreach ($model_entries as $model_entry) { if (!isset($model_entry['fqcn'])) { continue; } $fqcn = $model_entry['fqcn']; $class_name = $model_entry['class'] ?? ''; // Skip if it extends Rsx_System_Model_Abstract if (static::php_is_subclass_of($fqcn, 'Rsx_System_Model_Abstract')) { continue; } // Model class MUST exist if it's in the manifest as a model // If it doesn't exist, this indicates a serious problem with the manifest if (!class_exists($fqcn)) { shouldnt_happen("Model class {$fqcn} from manifest cannot be loaded. This should not happen - models extending Rsx_Model_Abstract must be loadable. File: " . ($model_entry['file'] ?? 'unknown')); } // Check if this is an abstract class $reflection = new ReflectionClass($fqcn); if ($reflection->isAbstract()) { continue; } // Get model metadata from manifest $file_path = $model_entry['file'] ?? ''; $metadata = isset(static::$data['data']['files'][$file_path]) ? static::$data['data']['files'][$file_path] : []; // Generate stub filename and paths $stub_filename = static::_sanitize_model_stub_filename($class_name) . '.js'; // Check if user has created their own JS class $user_class_exists = static::_check_user_model_class_exists($class_name); // Use Base_ prefix if user class exists $stub_class_name = $user_class_exists ? 'Base_' . $class_name : $class_name; $stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js'; $stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename; $stub_full_path = base_path($stub_relative_path); // Check if stub needs regeneration $needs_regeneration = true; if (file_exists($stub_full_path)) { // Get mtime of source PHP file $source_mtime = $metadata['mtime'] ?? 0; $stub_mtime = filemtime($stub_full_path); // Only regenerate if source is newer than stub if ($stub_mtime >= $source_mtime) { // Also check if the model metadata has changed // by comparing a hash of enums, relationships, and columns $model_metadata = []; // Get relationships $model_metadata['rel'] = $fqcn::get_relationships(); // Get enums if (property_exists($fqcn, 'enums')) { $model_metadata['enums'] = $fqcn::$enums ?? []; } // Get columns from models metadata if available if (isset(static::$data['data']['models'][$class_name]['columns'])) { $model_metadata['columns'] = static::$data['data']['models'][$class_name]['columns']; } $model_metadata_hash = md5(json_encode($model_metadata)); $old_metadata_hash = $metadata['model_metadata_hash'] ?? ''; if ($model_metadata_hash === $old_metadata_hash) { $needs_regeneration = false; } // Store the hash for future comparisons static::$data['data']['files'][$file_path]['model_metadata_hash'] = $model_metadata_hash; } } if ($needs_regeneration) { // Generate stub content $stub_content = static::_generate_model_stub_content($fqcn, $class_name, $stub_class_name); // Write stub file file_put_contents($stub_full_path, $stub_content); // Store the metadata hash for future comparisons if not already done if (!isset(static::$data['data']['files'][$file_path]['model_metadata_hash'])) { $model_metadata = []; // Get relationships $model_metadata['rel'] = $fqcn::get_relationships(); // Get enums if (property_exists($fqcn, 'enums')) { $model_metadata['enums'] = $fqcn::$enums ?? []; } // Get columns from models metadata if available if (isset(static::$data['data']['models'][$class_name]['columns'])) { $model_metadata['columns'] = static::$data['data']['models'][$class_name]['columns']; } static::$data['data']['files'][$file_path]['model_metadata_hash'] = md5(json_encode($model_metadata)); } } $generated_stubs[] = $stub_filename; // Add js_stub property to manifest data $metadata['js_stub'] = $stub_relative_path; // Write the updated metadata back to the manifest static::$data['data']['files'][$file_path]['js_stub'] = $stub_relative_path; // Add the stub file itself to the manifest $stat = stat($stub_full_path); static::$data['data']['files'][$stub_relative_path] = [ 'file' => $stub_relative_path, 'hash' => sha1_file($stub_full_path), 'mtime' => $stat['mtime'], 'size' => $stat['size'], 'extension' => 'js', 'class' => $stub_class_name, 'is_model_stub' => true, // Mark this as a generated model stub 'source_model' => $file_path, // Reference to the source model ]; } // Clean up orphaned stub files $existing_stubs = glob($stub_dir . '/*.js'); foreach ($existing_stubs as $existing_stub) { $filename = basename($existing_stub); if (!in_array($filename, $generated_stubs)) { // Remove from disk unlink($existing_stub); // Remove from manifest $stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $filename; if (isset(static::$data['data']['files'][$stub_relative_path])) { unset(static::$data['data']['files'][$stub_relative_path]); } } } } /** * Check if user has created a JavaScript model class */ private static function _check_user_model_class_exists(string $model_name): bool { // Check if there's a JS file with this class name in the manifest foreach (static::$data['data']['files'] as $file_path => $metadata) { if (isset($metadata['extension']) && $metadata['extension'] === 'js') { if (isset($metadata['class']) && $metadata['class'] === $model_name) { // Don't consider our own stubs if (!isset($metadata['is_stub']) && !isset($metadata['is_model_stub'])) { return true; } } } } return false; } /** * Sanitize model name for use as a filename */ private static function _sanitize_model_stub_filename(string $model_name): string { // Replace underscores with hyphens and lowercase // e.g., User_Model becomes user-model return strtolower(str_replace('_', '-', $model_name)); } /** * Generate JavaScript stub content for a model */ private static function _generate_model_stub_content(string $fqcn, string $class_name, string $stub_class_name): string { // Get model instance to introspect $model = new $fqcn(); // Get relationships $relationships = $fqcn::get_relationships(); // Get enums $enums = $fqcn::$enums ?? []; // Get table name $table = $model->getTable(); // Get columns from models metadata if available $columns = []; if (isset(static::$data['data']['models'][$class_name]['columns'])) { $columns = static::$data['data']['models'][$class_name]['columns']; } // Start building the stub content $content = "/**\n"; $content .= " * Auto-generated JavaScript stub for {$class_name}\n"; $content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n"; $content .= " * DO NOT EDIT - This file is automatically regenerated\n"; $content .= " */\n\n"; $content .= "class {$stub_class_name} extends Rsx_Js_Model {\n"; // Add static table property $content .= " static table = '{$table}';\n\n"; // Add static model name for API calls $content .= " static get name() {\n"; $content .= " return '{$class_name}';\n"; $content .= " }\n\n"; // Generate enum constants and methods foreach ($enums as $column => $enum_values) { // Generate constants foreach ($enum_values as $value => $props) { if (!empty($props['constant'])) { $content .= " static {$props['constant']} = {$value};\n"; } } if (!empty($enum_values)) { $content .= "\n"; } // Generate enum value getter $content .= " static {$column}_enum_val() {\n"; $content .= ' return ' . json_encode($enum_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); // Fix indentation in JSON output $content = preg_replace('/\n/', "\n ", $content); $content = rtrim($content) . ";\n"; $content .= " }\n\n"; // Generate enum label list $content .= " static {$column}_label_list() {\n"; $content .= " const values = {};\n"; foreach ($enum_values as $value => $props) { if (isset($props['label'])) { $label = addslashes($props['label']); $content .= " values[{$value}] = '{$label}';\n"; } } $content .= " return values;\n"; $content .= " }\n\n"; // Generate enum select method (for dropdowns) $content .= " static {$column}_enum_select() {\n"; $content .= " const values = this.{$column}_enum_val();\n"; $content .= " const result = {};\n"; $content .= " // Sort by order property if present\n"; $content .= " const sorted = Object.entries(values)\n"; $content .= " .sort(([,a], [,b]) => (a.order || 0) - (b.order || 0));\n"; $content .= " for (const [key, val] of sorted) {\n"; $content .= " // Skip if selectable is false\n"; $content .= " if (val.selectable !== false) {\n"; $content .= " result[key] = val.label || key;\n"; $content .= " }\n"; $content .= " }\n"; $content .= " return result;\n"; $content .= " }\n\n"; } // Generate relationship methods foreach ($relationships as $relationship) { $content .= " /**\n"; $content .= " * Fetch {$relationship} relationship\n"; $content .= " * @returns {Promise} Related model instance(s) or false\n"; $content .= " */\n"; $content .= " async {$relationship}() {\n"; $content .= " if (!this.id) {\n"; $content .= " shouldnt_happen('Cannot fetch relationship without id property');\n"; $content .= " }\n\n"; $content .= " const response = await $.ajax({\n"; $content .= " url: `/_fetch_rel/{$class_name}/\${this.id}/{$relationship}`,\n"; $content .= " method: 'POST',\n"; $content .= " dataType: 'json'\n"; $content .= " });\n\n"; $content .= " // Handle response based on type\n"; $content .= " if (response === false || response === null) {\n"; $content .= " return response;\n"; $content .= " }\n\n"; $content .= " // __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.\n"; $content .= " // PHP models add \"__MODEL\": \"ClassName\" to JSON, JavaScript uses it to create proper instances.\n"; $content .= " // This provides typed model objects instead of plain JSON, with methods and type checking.\n\n"; $content .= " // Use the recursive processor to scan response and instantiate any models found.\n"; $content .= " // This generated stub method delegates to the base class processor.\n"; $content .= " return Rsx_Js_Model._instantiate_models_recursive(response);\n"; $content .= " }\n\n"; } $content .= "}\n\n"; // Export the class globally $content .= "// Make the class globally available\n"; $content .= "window.{$stub_class_name} = {$stub_class_name};\n"; // If using Base_ prefix, also create alias without prefix for convenience if ($stub_class_name !== $class_name) { $content .= "\n// Create alias without Base_ prefix if user class doesn't exist\n"; $content .= "if (typeof window.{$class_name} === 'undefined') {\n"; $content .= " window.{$class_name} = {$stub_class_name};\n"; $content .= "}\n"; } return $content; } /** * Sanitize controller name for use as a filename */ private static function _sanitize_stub_filename(string $controller_name): string { // Replace underscores with hyphens and lowercase // e.g., User_Controller becomes user-controller return strtolower(str_replace('_', '-', $controller_name)); } /** * Generate JavaScript stub content for a controller */ private static function _generate_stub_content(string $controller_name, array $api_methods): string { $content = "/**\n"; $content .= " * Auto-generated JavaScript stub for {$controller_name}\n"; $content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n"; $content .= " * DO NOT EDIT - This file is automatically regenerated\n"; $content .= " */\n\n"; $content .= "class {$controller_name} {\n"; foreach ($api_methods as $method_name => $method_info) { $content .= " /**\n"; $content .= " * Call {$controller_name}::{$method_name} via Ajax.call()\n"; $content .= " * @param {...*} args - Arguments to pass to the method\n"; $content .= " * @returns {Promise<*>}\n"; $content .= " */\n"; $content .= " static async {$method_name}(...args) {\n"; $content .= " return Ajax.call('{$controller_name}', '{$method_name}', args);\n"; $content .= " }\n\n"; } $content .= "}\n"; return $content; } /** * Get or create the kernel instance */ protected static function __get_kernel(): ManifestKernel { if (static::$kernel === null) { static::$kernel = app(ManifestKernel::class); } return static::$kernel; } /** * Get the full cache file path */ protected static function __get_cache_file_path(): string { return base_path() . static::CACHE_FILE; } // move to lower soon private static function __validate_cached_data() { // If cache exists, check if anything changed $files = static::__get_rsx_files(); $any_changes = false; // Check for changed files foreach ($files as $file) { if (static::__has_changed($file)) { return false; } } // Check for deleted files if (!$any_changes) { $existing_files = array_flip($files); foreach (array_keys(static::$data['data']['files']) as $cached_file) { // Skip storage files - they're not part of the manifest if (str_starts_with($cached_file, 'storage/')) { continue; } if (!isset($existing_files[$cached_file])) { // Only show the message once per page load if (!self::$__shown_rescan_message) { console_debug('MANIFEST', '* Deleted file ' . $cached_file . ' is triggering manifest rescan *'); self::$__shown_rescan_message = true; } return false; } } } return true; } /** * Check for duplicate base class names within the same file type * Throws a fatal error if any base class name appears in multiple files of the same type */ protected static function __check_unique_base_class_names(): void { // Group classes by extension first, then by class name $classes_by_extension = []; foreach (static::$data['data']['files'] as $file => $metadata) { if (isset($metadata['class']) && !empty($metadata['class'])) { $base_class_name = $metadata['class']; $extension = $metadata['extension'] ?? ''; // Group JavaScript-like files together if (in_array($extension, ['js', 'jsx', 'ts', 'tsx'])) { $extension = 'js'; } if (!isset($classes_by_extension[$extension])) { $classes_by_extension[$extension] = []; } if (!isset($classes_by_extension[$extension][$base_class_name])) { $classes_by_extension[$extension][$base_class_name] = []; } $classes_by_extension[$extension][$base_class_name][] = $file; } } // Check for duplicates within each extension type foreach ($classes_by_extension as $extension => $base_class_files) { foreach ($base_class_files as $class_name => $files) { if (count($files) > 1) { $file_type = $extension === 'php' ? 'PHP' : ($extension === 'js' ? 'JavaScript' : $extension); throw new \RuntimeException( "Fatal: Duplicate {$file_type} class name '{$class_name}' found in multiple files:\n" . ' - ' . implode("\n - ", $files) . "\n" . 'Each class name must be unique within the same file type.' ); } } } } /** * Collate files by class names and build inheritance indices * * This method creates two types of indices for both JavaScript and PHP classes: * * 1. Class indices (js_classes, php_classes): * Maps class names to their file metadata for O(1) lookups by class name * * 2. Subclass indices (js_subclass_index, php_subclass_index): * Maps each parent class name to an array of its direct subclasses * Example: Rsx_Controller_Abstract => ['Demo_Index_Controller', 'Backend_Controller', ...] * * The subclass indices enable efficient inheritance checking without iterating * through the entire manifest. Instead of O(n) complexity for finding subclasses, * we get O(1) direct subclass lookups. * * @return void */ protected static function __collate_files_by_classes() { foreach (['js', 'php'] as $ext) { static::$data['data'][$ext . '_classes'] = []; // Step 1: Index files by class name for quick lookups // This creates a map of className => filename (not full metadata to save space) foreach (static::$data['data']['files'] as $file => $filedata) { if ($filedata['extension'] == $ext && !empty($filedata['class'])) { static::$data['data'][$ext . '_classes'][$filedata['class']] = $file; } } // Step 2: Build parent chain index for each class // This traverses up the inheritance tree and collects all parent classes static::$data['data'][$ext . '_subclass_index'] = []; foreach (static::$data['data'][$ext . '_classes'] as $class => $file_path) { // Get metadata from files array $classdata = static::$data['data']['files'][$file_path]; // Walk up the parent chain until we reach the root do { $extends = !empty($classdata['extends']) ? $classdata['extends'] : null; if (!empty($extends)) { // Initialize the parent chain array if needed if (empty(static::$data['data'][$ext . '_subclass_index'][$extends])) { static::$data['data'][$ext . '_subclass_index'][$extends] = []; } // Add this parent to the chain static::$data['data'][$ext . '_subclass_index'][$extends][] = $class; // Move up to the parent's metadata (if it exists in manifest) if (!empty(static::$data['data'][$ext . '_classes'][$extends])) { $parent_file = static::$data['data'][$ext . '_classes'][$extends]; $classdata = static::$data['data']['files'][$parent_file]; } else { // Parent not in manifest (e.g., Laravel framework class), stop here $classdata = null; } } else { // No parent, we've reached the root $classdata = null; } } while (!empty($classdata)); } } } /** * Load changed PHP files and their dependencies * * This method loads only the PHP files that have changed and ensures * their parent class hierarchies are loaded first. * * @param array $changed_files Array of changed file paths * @return void */ protected static function __load_changed_php_files(array $changed_files): void { // Filter to only PHP files $trait_files = []; $class_files = []; foreach ($changed_files as $file) { if (isset(static::$data['data']['files'][$file]['extension']) && static::$data['data']['files'][$file]['extension'] === 'php' && isset(static::$data['data']['files'][$file]['fqcn'])) { // Separate traits from classes - traits must be loaded first if (isset(static::$data['data']['files'][$file]['is_trait']) && static::$data['data']['files'][$file]['is_trait']) { $trait_files[] = $file; } else { $class_files[] = $file; } } } // Load traits first (they have no dependencies and are used by classes) foreach ($trait_files as $file) { $fqcn = static::$data['data']['files'][$file]['fqcn']; static::_load_class_hierarchy($fqcn, static::$data); } // Then load classes with their hierarchies foreach ($class_files as $file) { $fqcn = static::$data['data']['files'][$file]['fqcn']; static::_load_class_hierarchy($fqcn, static::$data); } } /** * Extract reflection data only for changed files * Uses caching to avoid re-extracting unchanged files */ protected static function __extract_reflection_for_changed_files(array $changed_files): void { $cache_dir = storage_path('rsx-tmp/persistent/php_reflection'); // Ensure cache directory exists if (!is_dir($cache_dir)) { mkdir($cache_dir, 0755, true); } // Build a set of changed files for quick lookup $changed_files_set = array_flip($changed_files); // Process ALL PHP files to restore cached data or extract new reflection foreach (static::$data['data']['files'] as $file => &$metadata) { // Skip non-PHP files if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { continue; } // Skip files without classes if (!isset($metadata['fqcn'])) { continue; } $fqcn = $metadata['fqcn']; $cache_key = $metadata['hash']; $cache_file = $cache_dir . '/' . $cache_key . '.json'; // Get absolute path - rsx/ files are in project root, not system/ if (str_starts_with($file, 'rsx/')) { $absolute_path = rsxrealpath(base_path('../' . $file)); } else { $absolute_path = rsxrealpath(base_path($file)); } // Check if this file changed $file_changed = isset($changed_files_set[$file]); // Try to use cached data if file hasn't changed if (!$file_changed && file_exists($cache_file)) { $cache_mtime = filemtime($cache_file); $source_mtime = filemtime($absolute_path); // If cache is newer than source, use cached data if ($cache_mtime >= $source_mtime) { $cached_data = json_decode(file_get_contents($cache_file), true); if ($cached_data !== null) { // Merge cached reflection data into manifest without breaking the reference foreach ($cached_data as $key => $value) { $metadata[$key] = $value; } continue; } } } // Need fresh reflection data - ensure class and its hierarchy are loaded static::_load_class_hierarchy($fqcn, static::$data); // Extract reflection data (path already normalized with realpath above) static::__extract_reflection_data($absolute_path, $fqcn, $metadata); // Cache the reflection data $reflection_data = []; // Add abstract property for correct subclass filtering if (isset($metadata['abstract'])) { $reflection_data['abstract'] = $metadata['abstract']; } if (isset($metadata['attributes'])) { $reflection_data['attributes'] = $metadata['attributes']; } if (isset($metadata['public_static_methods'])) { $reflection_data['public_static_methods'] = $metadata['public_static_methods']; } if (isset($metadata['public_instance_methods'])) { $reflection_data['public_instance_methods'] = $metadata['public_instance_methods']; } if (isset($metadata['properties'])) { $reflection_data['properties'] = $metadata['properties']; } if (isset($metadata['implements'])) { $reflection_data['implements'] = $metadata['implements']; } if (isset($metadata['traits'])) { $reflection_data['traits'] = $metadata['traits']; } if (!empty($reflection_data)) { file_put_contents($cache_file, json_encode($reflection_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } } } /** * Load all PHP files in dependency order * This ensures base classes are loaded before their subclasses * Must be called after Phase 2 (basic metadata extraction) completes * @deprecated Use __load_changed_php_files() for incremental builds */ /** * Run Php_Fixer on all PHP files in rsx/ and app/RSpade/ * Called before Phase 2 parsing to ensure all files are fixed * * @param array $changed_files List of changed files from Phase 1 * @return array List of files that were modified by Php_Fixer */ protected static function __run_php_fixer(array $changed_files): array { $modified_files = []; // Build hash array of all PHP classes to detect structural changes $class_structure_hash_data = []; foreach (static::$data['data']['files'] as $file_path => $metadata) { // Only process PHP files with classes if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { continue; } if (!isset($metadata['class'])) { continue; } // Build hash entry: filename => ClassName:ParentClass $class_name = $metadata['class']; $extends = $metadata['extends'] ?? ''; $class_structure_hash_data[$file_path] = $class_name . ':' . $extends; } // Calculate hash of class structure $new_class_structure_hash = sha1(json_encode($class_structure_hash_data)); // Check if class structure has changed $previous_hash = static::$data['data']['php_fixer_hash'] ?? null; $structure_changed = ($previous_hash !== $new_class_structure_hash); if ($structure_changed) { // Class structure changed - fix ALL PHP files in rsx/ and app/RSpade/ $php_files_to_fix = []; foreach (static::$data['data']['files'] as $file_path => $metadata) { // Only process PHP files if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { continue; } // Only process files in rsx/ or app/RSpade/ if (!str_starts_with($file_path, 'rsx/') && !str_starts_with($file_path, 'app/RSpade/')) { continue; } $php_files_to_fix[] = $file_path; } // Run Php_Fixer on all files and collect modified ones foreach ($php_files_to_fix as $file_path) { if (\App\RSpade\Core\PHP\Php_Fixer::fix($file_path, static::$data)) { $modified_files[] = $file_path; } } // Store updated hash static::$data['data']['php_fixer_hash'] = $new_class_structure_hash; } else { // Class structure unchanged - only fix changed PHP files with classes $php_files_to_fix = []; foreach ($changed_files as $file_path) { // Check if this is a PHP file with a class in the manifest if (!isset(static::$data['data']['files'][$file_path])) { continue; } $metadata = static::$data['data']['files'][$file_path]; // Only process PHP files with classes if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { continue; } if (!isset($metadata['class'])) { continue; } $php_files_to_fix[] = $file_path; } // Run Php_Fixer on changed files only and collect modified ones foreach ($php_files_to_fix as $file_path) { if (\App\RSpade\Core\PHP\Php_Fixer::fix($file_path, static::$data)) { $modified_files[] = $file_path; } } } return $modified_files; } protected static function __load_php_files_in_dependency_order(): void { if (!isset(static::$data['data']['files'])) { throw new \RuntimeException( 'Fatal: Manifest::load_php_files_in_dependency_order() called but manifest data structure is not initialized. ' . "This shouldn't happen - Phase 2 should have populated the files array." ); } $files = static::$data['data']['files']; $loaded = []; $to_load = []; // Build list of files with class information foreach ($files as $file_path => $metadata) { // Check for PHP files by extension if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { continue; } if (!isset($metadata['class'])) { // No class defined, include it immediately $full_path = base_path($file_path); if (file_exists($full_path)) { include_once $full_path; } continue; } $to_load[$file_path] = [ 'class' => $metadata['class'] ?? null, 'extends' => $metadata['extends'] ?? null, 'namespace' => $metadata['namespace'] ?? null, 'fqcn' => $metadata['fqcn'] ?? null, ]; } // Load files in dependency order $max_iterations = count($to_load) + 10; // Prevent infinite loop $iteration = 0; while (!empty($to_load) && $iteration < $max_iterations) { $iteration++; $loaded_this_round = false; foreach ($to_load as $file_path => $info) { // Check if dependencies are met $can_load = true; if (!empty($info['extends'])) { // Check if parent class is already loaded $parent_loaded = false; // Check if it's a Laravel/PHP built-in class if (class_exists($info['extends'], false) || interface_exists($info['extends'], false)) { $parent_loaded = true; } else { // Check if parent is in our loaded list foreach ($loaded as $loaded_info) { if ($loaded_info['class'] === $info['extends']) { $parent_loaded = true; break; } } } if (!$parent_loaded) { $can_load = false; } } if ($can_load) { // Load the file $full_path = base_path($file_path); if (file_exists($full_path)) { include_once $full_path; } // Mark as loaded $loaded[$file_path] = $info; unset($to_load[$file_path]); $loaded_this_round = true; } } // If nothing was loaded this round, we might have circular dependencies // Load remaining files anyway if (!$loaded_this_round && !empty($to_load)) { foreach ($to_load as $file_path => $info) { $full_path = base_path($file_path); if (file_exists($full_path)) { include_once $full_path; } } break; } } } // /** // * Extract reflection data for all PHP files // * Must be called after Phase 3 (dependency loading) completes // */ // protected static function __extract_all_reflection_data(): void // { // if (!isset(static::$data['data']['files'])) { // throw new \RuntimeException( // 'Fatal: Manifest::extract_all_reflection_data() called but manifest data structure is not initialized. ' . // "This shouldn't happen - Phase 2 should have populated the files array." // ); // } // foreach (static::$data['data']['files'] as $file_path => &$metadata) { // // Only process PHP files with classes // if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { // continue; // } // if (!isset($metadata['fqcn'])) { // continue; // } // var_dump($file_path); // // Extract reflection data (class should already be loaded) // static::__extract_reflection_data(base_path($file_path), $metadata['fqcn'], $metadata); // } // } /** * Build the autoloader class map for simplified class name resolution * Maps simple class names to their fully qualified class names * @return array Map of simple names to arrays of FQCNs */ protected static function __build_autoloader_class_map(): array { $class_map = []; // First, collect classes from the manifest files foreach (static::$data['data']['files'] as $file_path => $file_data) { if (isset($file_data['class']) && isset($file_data['namespace'])) { $simple_name = $file_data['class']; $fqcn = $file_data['namespace'] . '\\' . $simple_name; if (!isset($class_map[$simple_name])) { $class_map[$simple_name] = []; } $class_map[$simple_name][] = $fqcn; } } // Second, scan app/RSpade directory for additional classes $rspade_path = base_path('app/RSpade'); if (is_dir($rspade_path)) { $rspade_classes = static::__scan_directory_for_classes($rspade_path); foreach ($rspade_classes as $simple_name => $fqcns) { foreach ($fqcns as $fqcn) { if (!isset($class_map[$simple_name])) { $class_map[$simple_name] = []; } // Only add if not already present if (!in_array($fqcn, $class_map[$simple_name])) { $class_map[$simple_name][] = $fqcn; } } } } return $class_map; } /** * Scan a directory for PHP classes, excluding vendor directories * @param string $directory The directory to scan * @return array Map of simple class names to FQCNs */ protected static function __scan_directory_for_classes(string $directory): array { $classes = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { // Skip non-PHP files if ($file->getExtension() !== 'php') { continue; } // Skip vendor directories $path = $file->getPathname(); if (strpos($path, '/vendor/') !== false) { continue; } // Extract class information using token parsing $content = file_get_contents($path); $tokens = token_get_all($content); $namespace = ''; $class_name = ''; $getting_namespace = false; $getting_class = false; foreach ($tokens as $i => $token) { if (is_array($token)) { if ($token[0] === T_NAMESPACE) { $getting_namespace = true; $namespace = ''; } elseif ($token[0] === T_CLASS || $token[0] === T_INTERFACE || $token[0] === T_TRAIT) { // Make sure this isn't an anonymous class $next_token_idx = $i + 1; while ($next_token_idx < count($tokens) && is_array($tokens[$next_token_idx]) && $tokens[$next_token_idx][0] === T_WHITESPACE) { $next_token_idx++; } if ($next_token_idx < count($tokens) && is_array($tokens[$next_token_idx]) && $tokens[$next_token_idx][0] === T_STRING) { $getting_class = true; } } elseif ($getting_namespace && ($token[0] === T_NAME_QUALIFIED || $token[0] === T_STRING || $token[0] === T_NS_SEPARATOR)) { $namespace .= $token[1]; } elseif ($getting_class && $token[0] === T_STRING) { $class_name = $token[1]; $getting_class = false; // We have both namespace and class name, add to map if ($class_name) { $fqcn = $namespace ? $namespace . '\\' . $class_name : $class_name; if (!isset($classes[$class_name])) { $classes[$class_name] = []; } $classes[$class_name][] = $fqcn; } } } else { // Non-array token (like ; or {) if ($token === ';' || $token === '{') { $getting_namespace = false; } } } } return $classes; } /** * Process a single file and extract comprehensive metadata * @param string $file_path Relative path to file */ protected static function __process_file(string $file_path): array { $absolute_path = base_path($file_path); $stat = stat($absolute_path); $extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION)); // Handle .blade.php as special case if (str_ends_with($file_path, '.blade.php')) { $extension = 'blade.php'; } $data = [ 'file' => $file_path, // Store relative path 'hash' => sha1_file($absolute_path), 'mtime' => $stat['mtime'], 'size' => $stat['size'], 'extension' => $extension, ]; // Use kernel for basic processing (kernel expects absolute path) $kernel = static::__get_kernel(); $kernel_data = $kernel->process($absolute_path, $data); $data = array_merge($data, $kernel_data); // Add advanced extraction based on file type (pass absolute path) switch ($extension) { case 'php': $php_metadata = \App\RSpade\Core\PHP\Php_Parser::parse($absolute_path, static::$data); $data = array_merge($data, $php_metadata); // Php_Parser::parse() may have modified the file via Php_Fixer in development mode // Recalculate file stats to reflect any changes made during parsing if (!app()->environment('production')) { clearstatcache(true, $absolute_path); $updated_stat = stat($absolute_path); $data['hash'] = sha1_file($absolute_path); $data['mtime'] = $updated_stat['mtime']; $data['size'] = $updated_stat['size']; } break; case 'js': $js_metadata = \App\RSpade\Core\JavaScript\Js_Parser::extract_metadata($absolute_path); $data = array_merge($data, $js_metadata); break; case 'blade.php': case 'phtml': static::__extract_view_info($absolute_path, $data); break; default: // Check if this extension has a registered handler if (ExtensionRegistry::process_file($extension, $absolute_path, $data)) { // Handler processed the file break; } } return $data; } /** * Extract public static methods and their attributes using PHP reflection * Note: This is called in Phase 4 after all PHP files have been loaded */ protected static function __extract_reflection_data(string $file_path, string $full_class_name, array &$data): void { // Skip method indexing for traits - methods will be indexed when we process classes that use them if (isset($data['is_trait']) && $data['is_trait']) { return; } // Class MUST exist if it's in the manifest // Fail loud if it doesn't - this indicates a serious problem if (!class_exists($full_class_name)) { shouldnt_happen("Class {$full_class_name} from manifest cannot be loaded. File: {$file_path}"); } $reflection = new ReflectionClass($full_class_name); // Extract whether the class is abstract if ($reflection->isAbstract()) { $data['abstract'] = true; } else { $data['abstract'] = false; } // Extract parent class FQCN if it exists $parent_class = $reflection->getParentClass(); if ($parent_class !== false) { $data['extends_fqcn'] = $parent_class->getName(); } // Extract class attributes (using simple names, not FQCNs) $class_attributes = []; foreach ($reflection->getAttributes() as $attribute) { $attr_full_name = $attribute->getName(); // Extract just the simple class name (e.g., "Route" from "App\RSpade\Core\Attributes\Route") $attr_simple_name = substr(strrchr($attr_full_name, '\\'), 1) ?: $attr_full_name; $attr_args = $attribute->getArguments(); if (!isset($class_attributes[$attr_simple_name])) { $class_attributes[$attr_simple_name] = []; } $class_attributes[$attr_simple_name][] = $attr_args; } if (!empty($class_attributes)) { $data['attributes'] = $class_attributes; } // Get list of trait files used by this class $trait_files = []; foreach ($reflection->getTraits() as $trait) { $trait_files[] = $trait->getFileName(); } // Extract ONLY public static methods - the only methods we care about in RSX $public_static_methods = []; // Normalize file_path for comparison (without resolving symlinks) $normalized_file_path = rsxrealpath($file_path); foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC) as $method) { // Include methods from: // 1. This file (the class itself) // 2. Traits used by this class (checked via file path) $method_file = $method->getFileName(); $is_from_trait = in_array($method_file, $trait_files); // Skip inherited methods from parent classes (but include trait methods) // Use rsxrealpath for comparison to avoid symlink resolution if (rsxrealpath($method_file) !== $normalized_file_path && !$is_from_trait) { continue; } $method_data = [ 'name' => $method->getName(), 'static' => true, // Always true since we filtered for static 'visibility' => 'public', // Always public since we filtered for public 'line' => $method->getStartLine(), ]; // For trait methods, store the file path (only if different from class file) // This enables IDE helpers to locate trait methods correctly if ($is_from_trait) { $method_data['file'] = $method_file; } // Check if method calls parent::methodname() // Use the correct file for trait methods $source_file_to_read = $is_from_trait ? $method_file : $file_path; try { $method_source = file($source_file_to_read); $start_line = $method->getStartLine() - 1; $end_line = $method->getEndLine() - 1; if ($method_source && $start_line >= 0 && $end_line < count($method_source)) { $method_body = implode('', array_slice($method_source, $start_line, $end_line - $start_line + 1)); // Look for parent::methodName() pattern $method_name = $method->getName(); if (preg_match('/parent\s*::\s*' . preg_quote($method_name, '/') . '\s*\(/i', $method_body)) { $method_data['__calls_parent'] = true; } } } catch (Exception $e) { // Silently skip if we can't read the source } // Extract method attributes (using simple names, not FQCNs) $method_attributes = []; foreach ($method->getAttributes() as $attribute) { $attr_full_name = $attribute->getName(); // Extract just the simple class name (e.g., "Route" from "App\RSpade\Core\Attributes\Route") $attr_simple_name = substr(strrchr($attr_full_name, '\\'), 1) ?: $attr_full_name; $attr_args = $attribute->getArguments(); if (!isset($method_attributes[$attr_simple_name])) { $method_attributes[$attr_simple_name] = []; } $method_attributes[$attr_simple_name][] = $attr_args; } if (!empty($method_attributes)) { $method_data['attributes'] = $method_attributes; } // Extract parameters with types $parameters = []; foreach ($method->getParameters() as $param) { $param_data = [ 'name' => $param->getName(), 'optional' => $param->isOptional(), ]; // Get type if available $type = $param->getType(); if ($type !== null) { $param_data['type'] = $type instanceof ReflectionNamedType ? $type->getName() : (string) $type; $param_data['nullable'] = $type->allowsNull(); } // Get default value if available if ($param->isDefaultValueAvailable()) { $param_data['default'] = $param->getDefaultValue(); } $parameters[] = $param_data; } if (!empty($parameters)) { $method_data['parameters'] = $parameters; } $public_static_methods[$method->getName()] = $method_data; } if (!empty($public_static_methods)) { $data['public_static_methods'] = $public_static_methods; } // Extract public instance methods for RequireParentCall and other rules $public_instance_methods = []; foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { // Skip inherited methods unless from same file if ($method->getFileName() !== $file_path) { continue; } // Skip static methods - we only want instance methods if ($method->isStatic()) { continue; } $method_data = [ 'name' => $method->getName(), 'static' => false, // Always false for instance methods 'visibility' => 'public', // Always public since we filtered for public 'line' => $method->getStartLine(), ]; // Check if method calls parent::methodname() try { $method_source = file($file_path); $start_line = $method->getStartLine() - 1; $end_line = $method->getEndLine() - 1; if ($method_source && $start_line >= 0 && $end_line < count($method_source)) { $method_body = implode('', array_slice($method_source, $start_line, $end_line - $start_line + 1)); // Look for parent::methodName() pattern $method_name = $method->getName(); if (preg_match('/parent\s*::\s*' . preg_quote($method_name, '/') . '\s*\(/i', $method_body)) { $method_data['__calls_parent'] = true; } } } catch (Exception $e) { // Silently skip if we can't read the source } // Extract method attributes (using simple names, not FQCNs) $method_attributes = []; foreach ($method->getAttributes() as $attribute) { $attr_full_name = $attribute->getName(); // Extract just the simple class name (e.g., "Route" from "App\RSpade\Core\Attributes\Route") $attr_simple_name = substr(strrchr($attr_full_name, '\\'), 1) ?: $attr_full_name; $attr_args = $attribute->getArguments(); if (!isset($method_attributes[$attr_simple_name])) { $method_attributes[$attr_simple_name] = []; } $method_attributes[$attr_simple_name][] = $attr_args; } if (!empty($method_attributes)) { $method_data['attributes'] = $method_attributes; } // Extract parameters with types $parameters = []; foreach ($method->getParameters() as $param) { $param_data = [ 'name' => $param->getName(), 'optional' => $param->isOptional(), ]; // Get type if available $type = $param->getType(); if ($type !== null) { $param_data['type'] = $type instanceof ReflectionNamedType ? $type->getName() : (string) $type; $param_data['nullable'] = $type->allowsNull(); } // Get default value if available if ($param->isDefaultValueAvailable()) { $param_data['default'] = $param->getDefaultValue(); } $parameters[] = $param_data; } if (!empty($parameters)) { $method_data['parameters'] = $parameters; } $public_instance_methods[$method->getName()] = $method_data; } // Always set public_instance_methods, even if empty, for consistency $data['public_instance_methods'] = $public_instance_methods; // Extract interfaces $interfaces = $reflection->getInterfaceNames(); if (!empty($interfaces)) { $data['implements'] = $interfaces; } // Extract traits $traits = $reflection->getTraitNames(); if (!empty($traits)) { $data['traits'] = $traits; } // Extract properties $properties = []; foreach ($reflection->getProperties() as $property) { if ($property->getDeclaringClass()->getName() === $full_class_name) { $properties[] = [ 'name' => $property->getName(), 'visibility' => $property->isPublic() ? 'public' : ($property->isProtected() ? 'protected' : 'private'), 'static' => $property->isStatic(), ]; } } if (!empty($properties)) { $data['properties'] = $properties; } } /** /** * Extract view information from template files */ protected static function __extract_view_info(string $file_path, array &$data): void { $content = file_get_contents($file_path); // Look for @id comment or directive if (preg_match('/(?:|@id\([\'"](\w+)[\'"]\))/', $content, $matches)) { $data['id'] = $matches[1] ?: $matches[2]; } // Extract blade sections if (str_ends_with($file_path, '.blade.php')) { $sections = []; if (preg_match_all('/@section\([\'"](\w+)[\'"]\)/', $content, $matches)) { $sections = $matches[1]; } if (!empty($sections)) { $data['sections'] = $sections; } // Extract extends if (preg_match('/@extends\([\'"]([^\'"\)]+)[\'"]\)/', $content, $matches)) { $data['extends'] = $matches[1]; } } } /** * Check if a file has changed (expects relative path) */ protected static function __has_changed(string $file): bool { if (isset(self::$__has_changed_cache[$file])) { return self::$__has_changed_cache[$file]; } if (!isset(static::$data['data']['files'][$file])) { // Only show the message once per page load if (!self::$__shown_rescan_message) { console_debug('MANIFEST', '* New file ' . $file . ' is triggering manifest rescan *'); self::$__shown_rescan_message = true; } $__has_changed_cache[$file] = true; return true; } $old = static::$data['data']['files'][$file]; $absolute_path = base_path($file); // Make sure file exists if (!file_exists($absolute_path)) { // Only show the message once per page load if (!self::$__shown_rescan_message) { console_debug('MANIFEST', '* File ' . $file . ' appears to be deleted in ' . $absolute_path . ', triggering manifest rescan *'); self::$__shown_rescan_message = true; } $__has_changed_cache[$file] = true; return true; } $current_size = filesize($absolute_path); // Stage 1: Size check if ($old['size'] != $current_size) { // Only show the message once per page load if (!self::$__shown_rescan_message) { console_debug('MANIFEST', '* File ' . $file . ' has changed size, triggering manifest rescan *'); self::$__shown_rescan_message = true; } $__has_changed_cache[$file] = true; return true; } // Stage 2: mtime check $current_mtime = filemtime($absolute_path); if ($old['mtime'] != $current_mtime) { // Only show the message once per page load if (!self::$__shown_rescan_message) { console_debug('MANIFEST', '* File ' . $file . ' has changed mtime, triggering manifest rescan *'); self::$__shown_rescan_message = true; } $__has_changed_cache[$file] = true; return true; } $__has_changed_cache[$file] = false; return false; } /** * Get all files in configured scan directories (returns relative paths) */ protected static function __get_rsx_files(): array { if (!empty(self::$__get_rsx_files_cache)) { return self::$__get_rsx_files_cache; } $base_path = base_path(); $scan_paths = config('rsx.manifest.scan_directories', ['rsx']); $files = []; foreach ($scan_paths as $scan_path) { $full_path = base_path($scan_path); // Check if path exists - throw fatal error if not (except for app/RSpade/temp) if (!file_exists($full_path)) { // Special case: silently skip app/RSpade/temp if it doesn't exist if ($scan_path === 'app/RSpade/temp') { continue; } throw new \RuntimeException( "Manifest scan path does not exist: '{$scan_path}'\n" . "Please ensure all paths in config('rsx.manifest.scan_directories') exist.\n" . 'Current configuration includes: ' . implode(', ', $scan_paths) ); } // If it's a file, add it directly if (is_file($full_path)) { // Convert to relative path and normalize to forward slashes $relative_path = str_replace($base_path . '/', '', $full_path); $relative_path = str_replace('\\', '/', $relative_path); // Remove leading slash if present $relative_path = ltrim($relative_path, '/'); $files[] = $relative_path; continue; } // If it's a directory, recursively scan it if (is_dir($full_path)) { $iterator = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator($full_path, RecursiveDirectoryIterator::SKIP_DOTS), function ($file, $key, $iterator) { $basename = $file->getBasename(); // Skip hidden files and directories (those starting with .) if (strpos($basename, '.') === 0) { return false; } // If it's a directory, check if it should be excluded if ($iterator->hasChildren()) { // Don't recurse into excluded directories $excluded_dirs = config('rsx.manifest.excluded_dirs', static::EXCLUDED_DIRS); return !in_array($basename, $excluded_dirs); } // It's a file, check if filename should be excluded $excluded_files = config('rsx.manifest.excluded_files', []); if (in_array($basename, $excluded_files)) { return false; } return true; }, ), RecursiveIteratorIterator::SELF_FIRST, ); foreach ($iterator as $file) { if ($file->isFile()) { $absolute_path = $file->getPathname(); // Convert to relative path and normalize to forward slashes $absolute_path = str_replace('\\', '/', $absolute_path); $base_path_normalized = str_replace('\\', '/', $base_path); $relative_path = str_replace($base_path_normalized . '/', '', $absolute_path); // Remove leading slash if present $relative_path = ltrim($relative_path, '/'); // Check for disallowed .old. naming pattern if (preg_match('/\\.old\\.\\w+$/', $relative_path)) { ManifestErrors::old_file_pattern($relative_path); } $files[] = $relative_path; } } } } // Hardcoded ./json includes $hardcoded_files = ['package-lock.json', 'composer.lock']; foreach ($hardcoded_files as $file) { if (file_exists($file) && !in_array($file, $files)) { $files[] = $file; } } // Remove duplicates and sort $files = array_unique($files); sort($files); self::$__get_rsx_files_cache = $files; return $files; } /** * Load cached manifest data */ private static function __load_cached_data() { $cache_file = static::__get_cache_file_path(); if (file_exists($cache_file)) { static::$data = include $cache_file; // Validate structure if (is_array(static::$data) && isset(static::$data['data']['files']) && count(static::$data['data']['files']) > 0) { // Check if manifest is marked as bad due to code quality violations if (isset(static::$data['data']['manifest_is_bad']) && static::$data['data']['manifest_is_bad']) { // Clear the data to force a rebuild static::$data = [ 'generated' => date('Y-m-d H:i:s'), 'hash' => '', 'data' => ['files' => []], ]; return false; } return true; } } // Cache doesn't exist or is invalid - return false without logging // Logging happens in init() after we determine we actually need to rebuild static::$data = [ 'generated' => date('Y-m-d H:i:s'), 'hash' => '', 'data' => ['files' => []], ]; return false; } /** * Validate manifest data for consistency */ protected static function __validate_manifest_data(): void { if (!isset(static::$data['data']['files'])) { throw new \RuntimeException( 'Fatal: Manifest::validate_manifest_data() called but manifest data structure is not initialized. ' . "This shouldn't happen - data should be populated before validation." ); } // Track unique names by file type $php_classes = []; $js_classes = []; $blade_ids = []; $jqhtml_ids = []; foreach (static::$data['data']['files'] as $file_path => $metadata) { $extension = $metadata['extension'] ?? ''; // Check PHP class uniqueness if ($extension === 'php' && isset($metadata['class'])) { $class = $metadata['class']; if (isset($php_classes[$class])) { throw new \RuntimeException( "Duplicate PHP class name detected: {$class}\n" . "Found in:\n" . " - {$php_classes[$class]}\n" . " - {$file_path}\n\n" . "PHP class names must be unique across all files.\n\n" . "To resolve: Add specificity to the class names by prefixing with directory segments.\n" . "For example, if a class named 'Index_Controller' resides in the 'demo' module directory,\n" . "rename it to 'Demo_Index_Controller'. Similarly, classes in nested directories like\n" . "'demo/admin/' could be named 'Demo_Admin_Index_Controller'. After renaming, refactor\n" . 'all usages of the old class name throughout the codebase to use the new, more specific name.' ); } $php_classes[$class] = $file_path; } // Check JavaScript class uniqueness if (in_array($extension, ['js', 'jsx', 'ts', 'tsx']) && isset($metadata['class'])) { $class = $metadata['class']; if (isset($js_classes[$class])) { throw new \RuntimeException( "Duplicate JavaScript class name detected: {$class}\n" . "Found in:\n" . " - {$js_classes[$class]}\n" . " - {$file_path}\n\n" . "JavaScript class names must be unique across all files.\n\n" . "To resolve: Add specificity to the class names by prefixing with directory segments.\n" . "For example, if a class named 'Base' resides in the 'demo' module directory,\n" . "rename it to 'Demo_Base'. Similarly, classes in nested directories like\n" . "'demo/admin/' could be named 'Demo_Admin_Base'. After renaming, refactor\n" . 'all usages of the old class name throughout the codebase to use the new, more specific name.' ); } $js_classes[$class] = $file_path; } // Check Blade ID uniqueness if ($extension === 'blade.php' && isset($metadata['id'])) { $id = $metadata['id']; if (isset($blade_ids[$id])) { throw new \RuntimeException( "Duplicate Blade @rsx_id detected: {$id}\n" . "Found in:\n" . " - {$blade_ids[$id]}\n" . " - {$file_path}\n\n" . "Blade @rsx_id values must be unique across all files.\n\n" . "To resolve: Add specificity to the @rsx_id by prefixing with directory segments.\n" . "For example, if a view with @rsx_id('Layout') resides in the 'demo' module directory,\n" . "change it to @rsx_id('Demo_Layout'). Similarly, views in nested directories like\n" . "'demo/sections/' could use @rsx_id('Demo_Sections_Layout'). After renaming, refactor\n" . 'all references to the old @rsx_id (in @rsx_extends, @rsx_include, etc.) to use the new name.' ); } $blade_ids[$id] = $file_path; } // Check Jqhtml component ID uniqueness if ($extension === 'jqhtml' && isset($metadata['id'])) { $id = $metadata['id']; if (isset($jqhtml_ids[$id])) { throw new \RuntimeException( "Duplicate jqhtml component name detected: {$id}\n" . "Found in:\n" . " - {$jqhtml_ids[$id]}\n" . " - {$file_path}\n\n" . "Jqhtml component names () must be unique across all files.\n\n" . "To resolve: Add specificity to the component name by prefixing with directory segments.\n" . "For example, if a component named 'Card' resides in the 'demo' module directory,\n" . "rename it to 'Demo_Card' in the tag. Similarly, components in nested directories\n" . "like 'demo/widgets/' could be named 'Demo_Widgets_Card'. After renaming, refactor all\n" . 'usages of the component (in Blade templates and JavaScript) to use the new, more specific name.' ); } $jqhtml_ids[$id] = $file_path; } // Check that controller actions don't have both Route and Ajax_Endpoint if ($extension === 'php' && isset($metadata['extends'])) { // Check if this is a controller (extends Rsx_Controller_Abstract) $is_controller = static::__is_controller_class($metadata); if ($is_controller && isset($metadata['public_static_methods'])) { foreach ($metadata['public_static_methods'] as $method_name => $method_info) { if (!isset($method_info['attributes'])) { continue; } $has_route = false; $has_ajax_endpoint = false; foreach ($method_info['attributes'] as $attr_name => $attr_instances) { if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) { $has_route = true; } if ($attr_name === 'Ajax_Endpoint' || str_ends_with($attr_name, '\\Ajax_Endpoint')) { $has_ajax_endpoint = true; } } if ($has_route && $has_ajax_endpoint) { $class_name = $metadata['class'] ?? 'Unknown'; throw new \RuntimeException( "Controller action cannot have both Route and Ajax_Endpoint attributes.\n" . "Class: {$class_name}\n" . "Method: {$method_name}\n" . "File: {$file_path}\n" . 'A controller action must be either a web route OR an AJAX endpoint, not both.' ); } } } } } } /** * Check if metadata represents a controller class * @param array $metadata File metadata * @return bool True if class extends Rsx_Controller_Abstract */ protected static function __is_controller_class(array $metadata): bool { $extends = $metadata['extends'] ?? ''; if ($extends === 'Rsx_Controller_Abstract') { return true; } // Check parent hierarchy $current_class = $extends; $max_depth = 10; while ($current_class && $max_depth-- > 0) { try { $parent_metadata = static::php_get_metadata_by_class($current_class); if (($parent_metadata['extends'] ?? '') === 'Rsx_Controller_Abstract') { return true; } $current_class = $parent_metadata['extends'] ?? ''; } catch (\RuntimeException $e) { // Check FQCN match if ($current_class === 'Rsx_Controller_Abstract' || $current_class === 'App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract') { return true; } break; } } return false; } /** * Generate VS Code IDE helper stubs for attributes and class aliases */ private static function __generate_vscode_stubs(): void { // Generate to project root (parent of system/) $project_root = dirname(base_path()); $stub_file = $project_root . '/._rsx_helper.php'; $output = " $metadata) { if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') { continue; } if (!isset($metadata['class'])) { continue; } // Collect attributes from the class itself if (isset($metadata['attributes'])) { foreach ($metadata['attributes'] as $attr_name => $attr_data) { // Extract simple attribute name (last part after backslash) $simple_attr_name = basename(str_replace('\\', '/', $attr_name)); $attributes[$simple_attr_name] = true; } } // Collect attributes from public static methods if (isset($metadata['public_static_methods'])) { foreach ($metadata['public_static_methods'] as $method_name => $method_info) { if (isset($method_info['attributes'])) { foreach ($method_info['attributes'] as $attr_name => $attr_data) { // Extract simple attribute name (last part after backslash) $simple_attr_name = basename(str_replace('\\', '/', $attr_name)); $attributes[$simple_attr_name] = true; } } } } } if (!empty($attributes)) { $output .= "namespace {\n"; foreach (array_keys($attributes) as $attr_name) { $output .= " #[\\Attribute(\\Attribute::TARGET_ALL | \\Attribute::IS_REPEATABLE)]\n"; $output .= " class {$attr_name} {\n"; $output .= " public function __construct(...\$args) {}\n"; $output .= " }\n\n"; } $output .= "}\n"; } file_put_contents($stub_file, $output); } /** * Save manifest data to cache */ protected static function __save(): void { // If manifest is marked as bad, handle specially if (static::$_manifest_is_bad) { // Check if manifest has been initialized if (!static::$_has_init) { return; // Don't save if not initialized } $cache_file = static::__get_cache_file_path(); // Check if cache file exists if (!file_exists($cache_file)) { return; // Don't save if cache doesn't exist yet } // Check if we've already marked it as bad if (isset(static::$data['data']['manifest_is_bad'])) { return; // Already marked, don't save again } // Mark as bad and save static::$data['data']['manifest_is_bad'] = true; // Fall through to normal save logic below } // Validate manifest data before saving static::__validate_manifest_data(); $cache_file = static::__get_cache_file_path(); // Ensure directory exists $dir = dirname($cache_file); if (!is_dir($dir)) { mkdir($dir, 0755, true); } // Sort files array by key for predictable output if (isset(static::$data['data']['files'])) { ksort(static::$data['data']['files']); } // Update metadata static::$data['generated'] = date('Y-m-d H:i:s'); // In development mode, use random hash for cache-busting on every request // if (env('APP_ENV') !== 'production') { // static::$data['hash'] = bin2hex(random_bytes(16)); // 32 character random hash // } else { // In production, use SHA-256 truncated to 32 chars for cryptographic security with consistent length static::$data['hash'] = substr(hash('sha256', json_encode(static::$data['data'])), 0, 32); // } $php_content = "is_called_during_manifest_scan()) { $rules_with_update[] = $rule_instance; } } } } // Process each changed file with each rule foreach ($changed_files as $file) { $absolute_path = base_path($file); // Skip if file doesn't exist if (!file_exists($absolute_path)) { continue; } // Initialize code_quality_metadata if not present if (!isset(static::$data['data']['files'][$file]['code_quality_metadata'])) { static::$data['data']['files'][$file]['code_quality_metadata'] = []; } // Process with each rule that has on_manifest_file_update foreach ($rules_with_update as $rule) { try { $metadata = $rule->on_manifest_file_update( $absolute_path, file_get_contents($absolute_path), static::$data['data']['files'][$file] ?? [] ); // Store metadata if returned if ($metadata !== null) { $rule_id = $rule->get_id(); static::$data['data']['files'][$file]['code_quality_metadata'][$rule_id] = $metadata; } } catch (Throwable $e) { // If the rule throws during metadata extraction, it's a violation // Mark manifest as bad and throw the error static::_set_manifest_is_bad(); throw $e; } } } } /** * Run code quality checks during manifest scan * Only runs in development mode after manifest changes * Throws fatal exception on first violation found */ protected static function __run_manifest_time_code_quality_checks(): void { // Create a minimal violation collector $collector = new \App\RSpade\CodeQuality\Support\ViolationCollector(); // Discover rules that should run during manifest scan // This uses direct file scanning, not the manifest, to avoid pollution $rules = \App\RSpade\CodeQuality\Support\RuleDiscovery::discover_rules( $collector, [], true // Only get rules with is_called_during_manifest_scan() = true ); foreach ($rules as $rule) { // Get file patterns this rule applies to $patterns = $rule->get_file_patterns(); // Check each file in the manifest foreach (static::$data['data']['files'] as $file_path => $metadata) { // Check if file matches any pattern $matches = false; foreach ($patterns as $pattern) { $pattern = str_replace('*', '.*', $pattern); if (preg_match('/^' . $pattern . '$/i', basename($file_path))) { $matches = true; break; } } if (!$matches) { continue; } // Get absolute path $absolute_path = base_path($file_path); // Skip if file doesn't exist if (!file_exists($absolute_path)) { continue; } // Read file contents $contents = file_get_contents($absolute_path); // Run the rule check - include code_quality_metadata if available $enhanced_metadata = $metadata; if (isset(static::$data['data']['files'][$file_path]['code_quality_metadata'][$rule->get_id()])) { $enhanced_metadata['rule_metadata'] = static::$data['data']['files'][$file_path]['code_quality_metadata'][$rule->get_id()]; } $rule->check($absolute_path, $contents, $enhanced_metadata); // Check if any violations were found - throw on first violation $violations = $collector->get_all(); if (!empty($violations)) { // Get the first violation $first_violation = $violations[0]; $data = $first_violation->to_array(); // Mark manifest as bad to force re-checking on next attempt static::_set_manifest_is_bad(); // Format file path relative to project root $relative_file = str_replace(base_path() . '/', '', $data['file']); // Build error message $error_message = "Code Quality Violation ({$data['type']}) - {$data['message']}\n\n"; $error_message .= "File: {$relative_file}:{$data['line']}\n\n"; if (!empty($data['code'])) { $error_message .= "Code:\n " . $data['code'] . "\n\n"; } if (!empty($data['resolution'])) { $error_message .= "Resolution:\n" . $data['resolution']; } // Throw fatal exception with the first violation, passing file and line for accurate error display throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException( $error_message, 0, null, $data['file'], $data['line'] ); } } } } /** * Get list of all database tables from manifest model metadata * * Returns only tables that have been indexed via Model_ManifestSupport * (i.e., tables with corresponding model classes) * * @return array Array of table names */ public static function db_get_tables(): array { static::init(); if (!isset(static::$data['data']['models'])) { return []; } $tables = []; foreach (static::$data['data']['models'] as $model_data) { if (isset($model_data['table'])) { $tables[] = $model_data['table']; } } return array_values(array_unique($tables)); } /** * Get columns for a specific table with their types from manifest * * Returns simplified column information extracted during manifest build. * Types are from Model_ManifestSupport::__parse_column_type() which simplifies * MySQL types (e.g., 'bigint', 'varchar(255)' -> 'integer', 'string') * * @param string $table Table name * @return array Associative array of column_name => type, or empty if table not found */ public static function db_get_table_columns(string $table): array { static::init(); if (!isset(static::$data['data']['models'])) { return []; } // Find the model for this table foreach (static::$data['data']['models'] as $model_data) { if (isset($model_data['table']) && $model_data['table'] === $table) { if (!isset($model_data['columns'])) { return []; } $result = []; foreach ($model_data['columns'] as $column_name => $column_data) { $result[$column_name] = $column_data['type'] ?? 'unknown'; } return $result; } } return []; } /** * Get columns of a specific type for a table from manifest * * Useful for finding all boolean columns (tinyint), integer columns, etc. * * @param string $table Table name * @param string $type Type to filter by (from Model_ManifestSupport simplified types) * @return array Array of column names matching the type */ public static function db_get_columns_by_type(string $table, string $type): array { $columns = static::db_get_table_columns($table); return array_keys(array_filter($columns, function ($col_type) use ($type) { return $col_type === $type; })); } }