Add semantic token highlighting for 'that' variable and comment file references in VS Code extension Add Phone_Text_Input and Currency_Input components with formatting utilities Implement client widgets, form standardization, and soft delete functionality Add modal scroll lock and update documentation Implement comprehensive modal system with form integration and validation Fix modal component instantiation using jQuery plugin API Implement modal system with responsive sizing, queuing, and validation support Implement form submission with validation, error handling, and loading states Implement country/state selectors with dynamic data loading and Bootstrap styling Revert Rsx::Route() highlighting in Blade/PHP files Target specific PHP scopes for Rsx::Route() highlighting in Blade Expand injection selector for Rsx::Route() highlighting Add custom syntax highlighting for Rsx::Route() and Rsx.Route() calls Update jqhtml packages to v2.2.165 Add bundle path validation for common mistakes (development mode only) Create Ajax_Select_Input widget and Rsx_Reference_Data controller Create Country_Select_Input widget with default country support Initialize Tom Select on Select_Input widgets Add Tom Select bundle for enhanced select dropdowns Implement ISO 3166 geographic data system for country/region selection Implement widget-based form system with disabled state support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
3808 lines
147 KiB
PHP
Executable File
3808 lines
147 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\RSpade\Core\Manifest;
|
|
|
|
use Exception;
|
|
use Illuminate\Support\Facades\File;
|
|
use RecursiveCallbackFilterIterator;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use ReflectionClass;
|
|
use ReflectionMethod;
|
|
use ReflectionNamedType;
|
|
use Throwable;
|
|
use App\RSpade\CodeQuality\RuntimeChecks\ManifestErrors;
|
|
use App\RSpade\Core\ExtensionRegistry;
|
|
use App\RSpade\Core\IntegrationRegistry;
|
|
use App\RSpade\Core\Kernels\ManifestKernel;
|
|
use App\RSpade\Core\Locks\RsxLocks;
|
|
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
|
|
|
/**
|
|
* Manifest - RSX File Discovery and Metadata Management System
|
|
*
|
|
* PURPOSE: Discovers, indexes, and caches metadata about all RSX files for fast lookups
|
|
*
|
|
* PATH HANDLING:
|
|
* - All file paths are stored as RELATIVE paths from base_path()
|
|
* - Use base_path($relative) to get absolute paths when needed
|
|
* - Laravel's base_path() always returns the project root directory
|
|
*
|
|
* PROCESSING MODEL - 5-PHASE ARCHITECTURE:
|
|
*
|
|
* When manifest needs updating (scan() or rebuild()):
|
|
* Phase 1: File Discovery - Scan directories and detect changes
|
|
* Phase 2: Parse Metadata - Token parsing for PHP/JS structure
|
|
* - NO reflection used, NO files loaded
|
|
* Phase 3: Load Dependencies - Load PHP classes in dependency order
|
|
* - Ensures all classes are available for reflection
|
|
* Phase 4: Extract Reflection - PHP reflection data extraction
|
|
* - Attributes, methods, parameters, etc.
|
|
* Phase 5: Process Modules - Run manifest support modules, build autoloader
|
|
* - Model_ManifestSupport extracts database metadata
|
|
* Phase 6: Generate Stubs - JavaScript API and model stubs
|
|
* Phase 7: Save & Finalize - Write cache, clear views, run quality checks
|
|
*
|
|
* When loading from valid cache:
|
|
* - Only loads cached data, NO file scanning or processing
|
|
* - Autoloader handles on-demand class loading
|
|
*
|
|
* CORE OPERATIONS:
|
|
* 1. init() - Ensures manifest is ready (loads cache + scans for updates, or rebuilds if no cache)
|
|
* 2. scan() - Incrementally updates manifest for changed files only (called by init())
|
|
* 3. rebuild() - Forces complete re-scan of all RSX files
|
|
* 4. clear() - Removes all cached manifest data
|
|
*
|
|
* FILE PROCESSING:
|
|
* - Scans /rsx/ directory recursively (excludes vendor, node_modules, etc.)
|
|
* - Extracts metadata from PHP files (classes, methods, attributes, namespaces)
|
|
* - Parses JavaScript/TypeScript files (imports, exports, classes)
|
|
* - Indexes Blade templates and views (sections, extends, view IDs)
|
|
* - Uses token parsing for basic info, reflection for detailed metadata
|
|
*
|
|
* QUERY METHODS:
|
|
* - php_find_class() - Locate PHP class by name
|
|
* - find_php_fqcn() - Locate PHP class by fully qualified name
|
|
* - js_find_class() - Locate JavaScript class
|
|
* - find_view() - Locate view template by ID
|
|
* - get_extending() - Find all classes extending a parent
|
|
* - get_with_attribute() - Find classes/methods with specific attributes
|
|
* - get_routes() - Extract all route definitions from attributes
|
|
* - get_stats() - Statistical summary of manifest contents
|
|
*
|
|
* DEBUGGING:
|
|
* - Use `php artisan manifest:dump` to view complete manifest contents
|
|
* - Supports JSON (default), YAML, and PHP export formats
|
|
* - Can filter by path or class name for targeted debugging
|
|
*
|
|
* DATA STRUCTURE (static::$data):
|
|
* [
|
|
* 'rsx/path/to/file.php' => [ // 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:
|
|
// ------------------------------------------------------------------------
|
|
// DEAD CODE REMOVED: _generate_js_api_stubs()
|
|
// Stub generation now handled by Controller_BundleIntegration
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
// ------------------------------------------------------------------------
|
|
// DEAD CODE REMOVED: _generate_stub_content()
|
|
// Stub generation now handled by Controller_BundleIntegration
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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('/(?:<!--\s*@id\s+(\w+)\s*-->|@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 (<Define:ComponentName>) 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 <Define:> 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;
|
|
$has_task = 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 ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
|
|
$has_task = true;
|
|
}
|
|
}
|
|
|
|
// Check for conflicting attributes
|
|
$conflicts = [];
|
|
if ($has_route) $conflicts[] = 'Route';
|
|
if ($has_ajax_endpoint) $conflicts[] = 'Ajax_Endpoint';
|
|
if ($has_task) $conflicts[] = 'Task';
|
|
|
|
if (count($conflicts) > 1) {
|
|
$class_name = $metadata['class'] ?? 'Unknown';
|
|
|
|
throw new \RuntimeException(
|
|
"Method cannot have multiple execution type attributes: " . implode(', ', $conflicts) . "\n" .
|
|
"Class: {$class_name}\n" .
|
|
"Method: {$method_name}\n" .
|
|
"File: {$file_path}\n" .
|
|
'A method must be either a Route, Ajax_Endpoint, OR Task, not multiple types.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = "<?php\n";
|
|
$output .= "/* @noinspection ALL */\n";
|
|
$output .= "// @formatter:off\n";
|
|
$output .= "// phpcs:ignoreFile\n\n";
|
|
$output .= "/**\n";
|
|
$output .= " * RSX Framework Attribute Stubs for IDE Support\n";
|
|
$output .= " *\n";
|
|
$output .= " * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY\n";
|
|
$output .= " *\n";
|
|
$output .= " * This file is automatically regenerated during manifest builds when:\n";
|
|
$output .= " * - Running `php artisan rsx:manifest:build`\n";
|
|
$output .= " * - File changes detected in development mode\n";
|
|
$output .= " * - Any PHP file with attributes is modified\n";
|
|
$output .= " *\n";
|
|
$output .= " * These are stub definitions to provide IDE autocomplete and eliminate warnings\n";
|
|
$output .= " * for RSX framework attributes. These classes are never actually loaded or used\n";
|
|
$output .= " * at runtime - they exist purely for IDE IntelliSense.\n";
|
|
$output .= " *\n";
|
|
$output .= " * This file should not be included in your code, only analyzed by your IDE!\n";
|
|
$output .= " */\n\n";
|
|
|
|
// Generate attribute stubs
|
|
$attributes = [];
|
|
|
|
foreach (static::$data['data']['files'] as $file_path => $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 = "<?php\n\n";
|
|
$php_content .= "// Generated manifest cache - DO NOT EDIT\n";
|
|
$php_content .= '// Generated: ' . static::$data['generated'] . "\n";
|
|
$php_content .= '// Files: ' . count(static::$data['data']['files']) . "\n";
|
|
$php_content .= '// Hash: ' . static::$data['hash'] . "\n\n";
|
|
$php_content .= 'return ' . var_export(static::$data, true) . ";\n";
|
|
|
|
file_put_contents($cache_file, $php_content);
|
|
}
|
|
|
|
/**
|
|
* Process code quality metadata extraction for changed files
|
|
* This runs during Phase 3.5, after PHP classes are loaded but before reflection
|
|
*/
|
|
protected static function __process_code_quality_metadata(array $changed_files): void
|
|
{
|
|
// Only run in development mode
|
|
if (env('APP_ENV') === 'production') {
|
|
return;
|
|
}
|
|
|
|
// Discover rules with on_manifest_file_update method
|
|
$rules_with_update = [];
|
|
$rule_files = glob(base_path('app/RSpade/CodeQuality/Rules/**/*Rule.php'));
|
|
|
|
foreach ($rule_files as $rule_file) {
|
|
$rule_class = null;
|
|
$content = file_get_contents($rule_file);
|
|
|
|
// Extract class name from file
|
|
if (preg_match('/class\s+(\w+)\s+extends/', $content, $matches)) {
|
|
$class_name = $matches[1];
|
|
|
|
// Extract namespace
|
|
if (preg_match('/namespace\s+([^;]+);/', $content, $ns_matches)) {
|
|
$namespace = $ns_matches[1];
|
|
$rule_class = $namespace . '\\' . $class_name;
|
|
}
|
|
}
|
|
|
|
if ($rule_class && class_exists($rule_class)) {
|
|
// Check if it has the on_manifest_file_update method
|
|
if (method_exists($rule_class, 'on_manifest_file_update')) {
|
|
$rule_instance = new $rule_class(new \App\RSpade\CodeQuality\Support\ViolationCollector());
|
|
|
|
// Check if it's a manifest-time rule
|
|
if ($rule_instance->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;
|
|
}));
|
|
}
|
|
}
|