Fix bin/publish: use correct .env path for rspade_system Fix bin/publish script: prevent grep exit code 1 from terminating script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
3957 lines
153 KiB
PHP
Executable File
3957 lines
153 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:
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Generate JavaScript stub files for PHP controllers with Internal API methods
|
|
* These stubs enable IDE autocomplete for Ajax.call() invocations from JavaScript
|
|
*/
|
|
protected static function _generate_js_api_stubs(): void
|
|
{
|
|
$stub_dir = storage_path('rsx-build/js-stubs');
|
|
|
|
// Create directory if it doesn't exist
|
|
if (!is_dir($stub_dir)) {
|
|
mkdir($stub_dir, 0755, true);
|
|
}
|
|
|
|
// Track generated stub files for cleanup
|
|
$generated_stubs = [];
|
|
|
|
// Process each file
|
|
foreach (static::$data['data']['files'] as $file_path => &$metadata) {
|
|
// Skip non-PHP files
|
|
if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') {
|
|
continue;
|
|
}
|
|
|
|
// Skip files without classes or public static methods
|
|
if (!isset($metadata['class']) || !isset($metadata['public_static_methods'])) {
|
|
continue;
|
|
}
|
|
|
|
// Check if this is a controller (extends Rsx_Controller_Abstract)
|
|
$class_name = $metadata['class'] ?? '';
|
|
if (!static::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
|
|
continue;
|
|
}
|
|
|
|
// Check if this controller has any Ajax_Endpoint methods
|
|
$api_methods = [];
|
|
foreach ($metadata['public_static_methods'] as $method_name => $method_info) {
|
|
if (!isset($method_info['attributes'])) {
|
|
continue;
|
|
}
|
|
|
|
// Check for Ajax_Endpoint attribute
|
|
$has_api_internal = false;
|
|
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
|
|
if ($attr_name === 'Ajax_Endpoint' ||
|
|
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
|
|
$has_api_internal = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($has_api_internal && isset($method_info['static']) && $method_info['static']) {
|
|
$api_methods[$method_name] = [
|
|
'name' => $method_name,
|
|
];
|
|
}
|
|
}
|
|
|
|
// If no API methods, remove js_stub property if it exists and skip
|
|
if (empty($api_methods)) {
|
|
if (isset($metadata['js_stub'])) {
|
|
unset($metadata['js_stub']);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Generate stub filename and paths
|
|
$controller_name = $metadata['class'];
|
|
$stub_filename = static::_sanitize_stub_filename($controller_name) . '.js';
|
|
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $stub_filename;
|
|
$stub_full_path = base_path($stub_relative_path);
|
|
|
|
// Check if stub needs regeneration
|
|
$needs_regeneration = true;
|
|
if (file_exists($stub_full_path)) {
|
|
// Get mtime of source PHP file
|
|
$source_mtime = $metadata['mtime'] ?? 0;
|
|
$stub_mtime = filemtime($stub_full_path);
|
|
|
|
// Only regenerate if source is newer than stub
|
|
if ($stub_mtime >= $source_mtime) {
|
|
// Also check if the API methods signature has changed
|
|
// by comparing a hash of the methods
|
|
$api_methods_hash = md5(json_encode($api_methods));
|
|
$old_api_hash = $metadata['api_methods_hash'] ?? '';
|
|
|
|
if ($api_methods_hash === $old_api_hash) {
|
|
$needs_regeneration = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store the API methods hash for future comparisons
|
|
$metadata['api_methods_hash'] = md5(json_encode($api_methods));
|
|
|
|
if ($needs_regeneration) {
|
|
// Generate stub content
|
|
$stub_content = static::_generate_stub_content($controller_name, $api_methods);
|
|
|
|
// Write stub file
|
|
file_put_contents($stub_full_path, $stub_content);
|
|
}
|
|
|
|
$generated_stubs[] = $stub_filename;
|
|
|
|
// Add js_stub property to manifest data (relative path)
|
|
$metadata['js_stub'] = $stub_relative_path;
|
|
|
|
// Add the stub file itself to the manifest
|
|
// This is critical because storage/rsx-build/js-stubs is not in scan directories
|
|
$stat = stat($stub_full_path);
|
|
static::$data['data']['files'][$stub_relative_path] = [
|
|
'file' => $stub_relative_path,
|
|
'hash' => sha1_file($stub_full_path),
|
|
'mtime' => $stat['mtime'],
|
|
'size' => $stat['size'],
|
|
'extension' => 'js',
|
|
'class' => $controller_name,
|
|
'is_stub' => true, // Mark this as a generated stub
|
|
'source_controller' => $file_path, // Reference to the source controller
|
|
];
|
|
}
|
|
|
|
// Clean up orphaned stub files (both from disk and manifest)
|
|
$existing_stubs = glob($stub_dir . '/*.js');
|
|
foreach ($existing_stubs as $existing_stub) {
|
|
$filename = basename($existing_stub);
|
|
if (!in_array($filename, $generated_stubs)) {
|
|
// Remove from disk
|
|
unlink($existing_stub);
|
|
|
|
// Remove from manifest
|
|
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $filename;
|
|
if (isset(static::$data['data']['files'][$stub_relative_path])) {
|
|
unset(static::$data['data']['files'][$stub_relative_path]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate JavaScript stub files for ORM models
|
|
* These stubs enable IDE autocomplete and provide relationship methods
|
|
*/
|
|
protected static function _generate_js_model_stubs(): void
|
|
{
|
|
$stub_dir = storage_path('rsx-build/js-model-stubs');
|
|
|
|
// Create directory if it doesn't exist
|
|
if (!is_dir($stub_dir)) {
|
|
mkdir($stub_dir, 0755, true);
|
|
}
|
|
|
|
// Track generated stub files for cleanup
|
|
$generated_stubs = [];
|
|
|
|
// Get all models from the manifest
|
|
$model_entries = static::get_extending('Rsx_Model_Abstract');
|
|
|
|
foreach ($model_entries as $model_entry) {
|
|
if (!isset($model_entry['fqcn'])) {
|
|
continue;
|
|
}
|
|
|
|
$fqcn = $model_entry['fqcn'];
|
|
$class_name = $model_entry['class'] ?? '';
|
|
|
|
// Skip if it extends Rsx_System_Model_Abstract
|
|
if (static::php_is_subclass_of($fqcn, 'Rsx_System_Model_Abstract')) {
|
|
continue;
|
|
}
|
|
|
|
// Model class MUST exist if it's in the manifest as a model
|
|
// If it doesn't exist, this indicates a serious problem with the manifest
|
|
if (!class_exists($fqcn)) {
|
|
shouldnt_happen("Model class {$fqcn} from manifest cannot be loaded. This should not happen - models extending Rsx_Model_Abstract must be loadable. File: " . ($model_entry['file'] ?? 'unknown'));
|
|
}
|
|
|
|
// Check if this is an abstract class
|
|
$reflection = new ReflectionClass($fqcn);
|
|
if ($reflection->isAbstract()) {
|
|
continue;
|
|
}
|
|
|
|
// Get model metadata from manifest
|
|
$file_path = $model_entry['file'] ?? '';
|
|
$metadata = isset(static::$data['data']['files'][$file_path]) ? static::$data['data']['files'][$file_path] : [];
|
|
|
|
// Generate stub filename and paths
|
|
$stub_filename = static::_sanitize_model_stub_filename($class_name) . '.js';
|
|
|
|
// Check if user has created their own JS class
|
|
$user_class_exists = static::_check_user_model_class_exists($class_name);
|
|
|
|
// Use Base_ prefix if user class exists
|
|
$stub_class_name = $user_class_exists ? 'Base_' . $class_name : $class_name;
|
|
$stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js';
|
|
|
|
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename;
|
|
$stub_full_path = base_path($stub_relative_path);
|
|
|
|
// Check if stub needs regeneration
|
|
$needs_regeneration = true;
|
|
if (file_exists($stub_full_path)) {
|
|
// Get mtime of source PHP file
|
|
$source_mtime = $metadata['mtime'] ?? 0;
|
|
$stub_mtime = filemtime($stub_full_path);
|
|
|
|
// Only regenerate if source is newer than stub
|
|
if ($stub_mtime >= $source_mtime) {
|
|
// Also check if the model metadata has changed
|
|
// by comparing a hash of enums, relationships, and columns
|
|
$model_metadata = [];
|
|
|
|
// Get relationships
|
|
$model_metadata['rel'] = $fqcn::get_relationships();
|
|
|
|
// Get enums
|
|
if (property_exists($fqcn, 'enums')) {
|
|
$model_metadata['enums'] = $fqcn::$enums ?? [];
|
|
}
|
|
|
|
// Get columns from models metadata if available
|
|
if (isset(static::$data['data']['models'][$class_name]['columns'])) {
|
|
$model_metadata['columns'] = static::$data['data']['models'][$class_name]['columns'];
|
|
}
|
|
|
|
$model_metadata_hash = md5(json_encode($model_metadata));
|
|
$old_metadata_hash = $metadata['model_metadata_hash'] ?? '';
|
|
|
|
if ($model_metadata_hash === $old_metadata_hash) {
|
|
$needs_regeneration = false;
|
|
}
|
|
|
|
// Store the hash for future comparisons
|
|
static::$data['data']['files'][$file_path]['model_metadata_hash'] = $model_metadata_hash;
|
|
}
|
|
}
|
|
|
|
if ($needs_regeneration) {
|
|
// Generate stub content
|
|
$stub_content = static::_generate_model_stub_content($fqcn, $class_name, $stub_class_name);
|
|
|
|
// Write stub file
|
|
file_put_contents($stub_full_path, $stub_content);
|
|
|
|
// Store the metadata hash for future comparisons if not already done
|
|
if (!isset(static::$data['data']['files'][$file_path]['model_metadata_hash'])) {
|
|
$model_metadata = [];
|
|
|
|
// Get relationships
|
|
$model_metadata['rel'] = $fqcn::get_relationships();
|
|
|
|
// Get enums
|
|
if (property_exists($fqcn, 'enums')) {
|
|
$model_metadata['enums'] = $fqcn::$enums ?? [];
|
|
}
|
|
|
|
// Get columns from models metadata if available
|
|
if (isset(static::$data['data']['models'][$class_name]['columns'])) {
|
|
$model_metadata['columns'] = static::$data['data']['models'][$class_name]['columns'];
|
|
}
|
|
|
|
static::$data['data']['files'][$file_path]['model_metadata_hash'] = md5(json_encode($model_metadata));
|
|
}
|
|
}
|
|
|
|
$generated_stubs[] = $stub_filename;
|
|
|
|
// Add js_stub property to manifest data
|
|
$metadata['js_stub'] = $stub_relative_path;
|
|
|
|
// Write the updated metadata back to the manifest
|
|
static::$data['data']['files'][$file_path]['js_stub'] = $stub_relative_path;
|
|
|
|
// Add the stub file itself to the manifest
|
|
$stat = stat($stub_full_path);
|
|
static::$data['data']['files'][$stub_relative_path] = [
|
|
'file' => $stub_relative_path,
|
|
'hash' => sha1_file($stub_full_path),
|
|
'mtime' => $stat['mtime'],
|
|
'size' => $stat['size'],
|
|
'extension' => 'js',
|
|
'class' => $stub_class_name,
|
|
'is_model_stub' => true, // Mark this as a generated model stub
|
|
'source_model' => $file_path, // Reference to the source model
|
|
];
|
|
}
|
|
|
|
// Clean up orphaned stub files
|
|
$existing_stubs = glob($stub_dir . '/*.js');
|
|
foreach ($existing_stubs as $existing_stub) {
|
|
$filename = basename($existing_stub);
|
|
if (!in_array($filename, $generated_stubs)) {
|
|
// Remove from disk
|
|
unlink($existing_stub);
|
|
|
|
// Remove from manifest
|
|
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $filename;
|
|
if (isset(static::$data['data']['files'][$stub_relative_path])) {
|
|
unset(static::$data['data']['files'][$stub_relative_path]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user has created a JavaScript model class
|
|
*/
|
|
private static function _check_user_model_class_exists(string $model_name): bool
|
|
{
|
|
// Check if there's a JS file with this class name in the manifest
|
|
foreach (static::$data['data']['files'] as $file_path => $metadata) {
|
|
if (isset($metadata['extension']) && $metadata['extension'] === 'js') {
|
|
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
|
|
// Don't consider our own stubs
|
|
if (!isset($metadata['is_stub']) && !isset($metadata['is_model_stub'])) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Sanitize model name for use as a filename
|
|
*/
|
|
private static function _sanitize_model_stub_filename(string $model_name): string
|
|
{
|
|
// Replace underscores with hyphens and lowercase
|
|
// e.g., User_Model becomes user-model
|
|
return strtolower(str_replace('_', '-', $model_name));
|
|
}
|
|
|
|
/**
|
|
* Generate JavaScript stub content for a model
|
|
*/
|
|
private static function _generate_model_stub_content(string $fqcn, string $class_name, string $stub_class_name): string
|
|
{
|
|
// Get model instance to introspect
|
|
$model = new $fqcn();
|
|
|
|
// Get relationships
|
|
$relationships = $fqcn::get_relationships();
|
|
|
|
// Get enums
|
|
$enums = $fqcn::$enums ?? [];
|
|
|
|
// Get table name
|
|
$table = $model->getTable();
|
|
|
|
// Get columns from models metadata if available
|
|
$columns = [];
|
|
if (isset(static::$data['data']['models'][$class_name]['columns'])) {
|
|
$columns = static::$data['data']['models'][$class_name]['columns'];
|
|
}
|
|
|
|
// Start building the stub content
|
|
$content = "/**\n";
|
|
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
|
|
$content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n";
|
|
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
|
|
$content .= " */\n\n";
|
|
|
|
$content .= "class {$stub_class_name} extends Rsx_Js_Model {\n";
|
|
|
|
// Add static table property
|
|
$content .= " static table = '{$table}';\n\n";
|
|
|
|
// Add static model name for API calls
|
|
$content .= " static get name() {\n";
|
|
$content .= " return '{$class_name}';\n";
|
|
$content .= " }\n\n";
|
|
|
|
// Generate enum constants and methods
|
|
foreach ($enums as $column => $enum_values) {
|
|
// Generate constants
|
|
foreach ($enum_values as $value => $props) {
|
|
if (!empty($props['constant'])) {
|
|
$content .= " static {$props['constant']} = {$value};\n";
|
|
}
|
|
}
|
|
if (!empty($enum_values)) {
|
|
$content .= "\n";
|
|
}
|
|
|
|
// Generate enum value getter
|
|
$content .= " static {$column}_enum_val() {\n";
|
|
$content .= ' return ' . json_encode($enum_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
// Fix indentation in JSON output
|
|
$content = preg_replace('/\n/', "\n ", $content);
|
|
$content = rtrim($content) . ";\n";
|
|
$content .= " }\n\n";
|
|
|
|
// Generate enum label list
|
|
$content .= " static {$column}_label_list() {\n";
|
|
$content .= " const values = {};\n";
|
|
foreach ($enum_values as $value => $props) {
|
|
if (isset($props['label'])) {
|
|
$label = addslashes($props['label']);
|
|
$content .= " values[{$value}] = '{$label}';\n";
|
|
}
|
|
}
|
|
$content .= " return values;\n";
|
|
$content .= " }\n\n";
|
|
|
|
// Generate enum select method (for dropdowns)
|
|
$content .= " static {$column}_enum_select() {\n";
|
|
$content .= " const values = this.{$column}_enum_val();\n";
|
|
$content .= " const result = {};\n";
|
|
$content .= " // Sort by order property if present\n";
|
|
$content .= " const sorted = Object.entries(values)\n";
|
|
$content .= " .sort(([,a], [,b]) => (a.order || 0) - (b.order || 0));\n";
|
|
$content .= " for (const [key, val] of sorted) {\n";
|
|
$content .= " // Skip if selectable is false\n";
|
|
$content .= " if (val.selectable !== false) {\n";
|
|
$content .= " result[key] = val.label || key;\n";
|
|
$content .= " }\n";
|
|
$content .= " }\n";
|
|
$content .= " return result;\n";
|
|
$content .= " }\n\n";
|
|
}
|
|
|
|
// Generate relationship methods
|
|
foreach ($relationships as $relationship) {
|
|
$content .= " /**\n";
|
|
$content .= " * Fetch {$relationship} relationship\n";
|
|
$content .= " * @returns {Promise} Related model instance(s) or false\n";
|
|
$content .= " */\n";
|
|
$content .= " async {$relationship}() {\n";
|
|
$content .= " if (!this.id) {\n";
|
|
$content .= " shouldnt_happen('Cannot fetch relationship without id property');\n";
|
|
$content .= " }\n\n";
|
|
$content .= " const response = await $.ajax({\n";
|
|
$content .= " url: `/_fetch_rel/{$class_name}/\${this.id}/{$relationship}`,\n";
|
|
$content .= " method: 'POST',\n";
|
|
$content .= " dataType: 'json'\n";
|
|
$content .= " });\n\n";
|
|
$content .= " // Handle response based on type\n";
|
|
$content .= " if (response === false || response === null) {\n";
|
|
$content .= " return response;\n";
|
|
$content .= " }\n\n";
|
|
$content .= " // __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.\n";
|
|
$content .= " // PHP models add \"__MODEL\": \"ClassName\" to JSON, JavaScript uses it to create proper instances.\n";
|
|
$content .= " // This provides typed model objects instead of plain JSON, with methods and type checking.\n\n";
|
|
$content .= " // Use the recursive processor to scan response and instantiate any models found.\n";
|
|
$content .= " // This generated stub method delegates to the base class processor.\n";
|
|
$content .= " return Rsx_Js_Model._instantiate_models_recursive(response);\n";
|
|
$content .= " }\n\n";
|
|
}
|
|
|
|
$content .= "}\n\n";
|
|
|
|
// Export the class globally
|
|
$content .= "// Make the class globally available\n";
|
|
$content .= "window.{$stub_class_name} = {$stub_class_name};\n";
|
|
|
|
// If using Base_ prefix, also create alias without prefix for convenience
|
|
if ($stub_class_name !== $class_name) {
|
|
$content .= "\n// Create alias without Base_ prefix if user class doesn't exist\n";
|
|
$content .= "if (typeof window.{$class_name} === 'undefined') {\n";
|
|
$content .= " window.{$class_name} = {$stub_class_name};\n";
|
|
$content .= "}\n";
|
|
}
|
|
|
|
return $content;
|
|
}
|
|
|
|
/**
|
|
* Sanitize controller name for use as a filename
|
|
*/
|
|
private static function _sanitize_stub_filename(string $controller_name): string
|
|
{
|
|
// Replace underscores with hyphens and lowercase
|
|
// e.g., User_Controller becomes user-controller
|
|
return strtolower(str_replace('_', '-', $controller_name));
|
|
}
|
|
|
|
/**
|
|
* Generate JavaScript stub content for a controller
|
|
*/
|
|
private static function _generate_stub_content(string $controller_name, array $api_methods): string
|
|
{
|
|
$content = "/**\n";
|
|
$content .= " * Auto-generated JavaScript stub for {$controller_name}\n";
|
|
$content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n";
|
|
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
|
|
$content .= " */\n\n";
|
|
|
|
$content .= "class {$controller_name} {\n";
|
|
|
|
foreach ($api_methods as $method_name => $method_info) {
|
|
$content .= " /**\n";
|
|
$content .= " * Call {$controller_name}::{$method_name} via Ajax.call()\n";
|
|
$content .= " * @param {...*} args - Arguments to pass to the method\n";
|
|
$content .= " * @returns {Promise<*>}\n";
|
|
$content .= " */\n";
|
|
$content .= " static async {$method_name}(...args) {\n";
|
|
$content .= " return Ajax.call('{$controller_name}', '{$method_name}', args);\n";
|
|
$content .= " }\n\n";
|
|
}
|
|
|
|
$content .= "}\n";
|
|
|
|
return $content;
|
|
}
|
|
|
|
/**
|
|
* Get or create the kernel instance
|
|
*/
|
|
protected static function __get_kernel(): ManifestKernel
|
|
{
|
|
if (static::$kernel === null) {
|
|
static::$kernel = app(ManifestKernel::class);
|
|
}
|
|
|
|
return static::$kernel;
|
|
}
|
|
|
|
/**
|
|
* Get the full cache file path
|
|
*/
|
|
protected static function __get_cache_file_path(): string
|
|
{
|
|
return base_path() . static::CACHE_FILE;
|
|
}
|
|
|
|
// move to lower soon
|
|
private static function __validate_cached_data()
|
|
{
|
|
// If cache exists, check if anything changed
|
|
$files = static::__get_rsx_files();
|
|
|
|
$any_changes = false;
|
|
|
|
// Check for changed files
|
|
foreach ($files as $file) {
|
|
if (static::__has_changed($file)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check for deleted files
|
|
if (!$any_changes) {
|
|
$existing_files = array_flip($files);
|
|
|
|
foreach (array_keys(static::$data['data']['files']) as $cached_file) {
|
|
// Skip storage files - they're not part of the manifest
|
|
if (str_starts_with($cached_file, 'storage/')) {
|
|
continue;
|
|
}
|
|
if (!isset($existing_files[$cached_file])) {
|
|
// Only show the message once per page load
|
|
if (!self::$__shown_rescan_message) {
|
|
console_debug('MANIFEST', '* Deleted file ' . $cached_file . ' is triggering manifest rescan *');
|
|
self::$__shown_rescan_message = true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check for duplicate base class names within the same file type
|
|
* Throws a fatal error if any base class name appears in multiple files of the same type
|
|
*/
|
|
protected static function __check_unique_base_class_names(): void
|
|
{
|
|
// Group classes by extension first, then by class name
|
|
$classes_by_extension = [];
|
|
|
|
foreach (static::$data['data']['files'] as $file => $metadata) {
|
|
if (isset($metadata['class']) && !empty($metadata['class'])) {
|
|
$base_class_name = $metadata['class'];
|
|
$extension = $metadata['extension'] ?? '';
|
|
|
|
// Group JavaScript-like files together
|
|
if (in_array($extension, ['js', 'jsx', 'ts', 'tsx'])) {
|
|
$extension = 'js';
|
|
}
|
|
|
|
if (!isset($classes_by_extension[$extension])) {
|
|
$classes_by_extension[$extension] = [];
|
|
}
|
|
|
|
if (!isset($classes_by_extension[$extension][$base_class_name])) {
|
|
$classes_by_extension[$extension][$base_class_name] = [];
|
|
}
|
|
|
|
$classes_by_extension[$extension][$base_class_name][] = $file;
|
|
}
|
|
}
|
|
|
|
// Check for duplicates within each extension type
|
|
foreach ($classes_by_extension as $extension => $base_class_files) {
|
|
foreach ($base_class_files as $class_name => $files) {
|
|
if (count($files) > 1) {
|
|
$file_type = $extension === 'php' ? 'PHP' : ($extension === 'js' ? 'JavaScript' : $extension);
|
|
|
|
throw new \RuntimeException(
|
|
"Fatal: Duplicate {$file_type} class name '{$class_name}' found in multiple files:\n" .
|
|
' - ' . implode("\n - ", $files) . "\n" .
|
|
'Each class name must be unique within the same file type.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collate files by class names and build inheritance indices
|
|
*
|
|
* This method creates two types of indices for both JavaScript and PHP classes:
|
|
*
|
|
* 1. Class indices (js_classes, php_classes):
|
|
* Maps class names to their file metadata for O(1) lookups by class name
|
|
*
|
|
* 2. Subclass indices (js_subclass_index, php_subclass_index):
|
|
* Maps each parent class name to an array of its direct subclasses
|
|
* Example: Rsx_Controller_Abstract => ['Demo_Index_Controller', 'Backend_Controller', ...]
|
|
*
|
|
* The subclass indices enable efficient inheritance checking without iterating
|
|
* through the entire manifest. Instead of O(n) complexity for finding subclasses,
|
|
* we get O(1) direct subclass lookups.
|
|
*
|
|
* @return void
|
|
*/
|
|
protected static function __collate_files_by_classes()
|
|
{
|
|
foreach (['js', 'php'] as $ext) {
|
|
static::$data['data'][$ext . '_classes'] = [];
|
|
|
|
// Step 1: Index files by class name for quick lookups
|
|
// This creates a map of className => filename (not full metadata to save space)
|
|
foreach (static::$data['data']['files'] as $file => $filedata) {
|
|
if ($filedata['extension'] == $ext && !empty($filedata['class'])) {
|
|
static::$data['data'][$ext . '_classes'][$filedata['class']] = $file;
|
|
}
|
|
}
|
|
|
|
// Step 2: Build parent chain index for each class
|
|
// This traverses up the inheritance tree and collects all parent classes
|
|
static::$data['data'][$ext . '_subclass_index'] = [];
|
|
foreach (static::$data['data'][$ext . '_classes'] as $class => $file_path) {
|
|
// Get metadata from files array
|
|
$classdata = static::$data['data']['files'][$file_path];
|
|
|
|
// Walk up the parent chain until we reach the root
|
|
do {
|
|
$extends = !empty($classdata['extends']) ? $classdata['extends'] : null;
|
|
if (!empty($extends)) {
|
|
// Initialize the parent chain array if needed
|
|
if (empty(static::$data['data'][$ext . '_subclass_index'][$extends])) {
|
|
static::$data['data'][$ext . '_subclass_index'][$extends] = [];
|
|
}
|
|
// Add this parent to the chain
|
|
static::$data['data'][$ext . '_subclass_index'][$extends][] = $class;
|
|
// Move up to the parent's metadata (if it exists in manifest)
|
|
if (!empty(static::$data['data'][$ext . '_classes'][$extends])) {
|
|
$parent_file = static::$data['data'][$ext . '_classes'][$extends];
|
|
$classdata = static::$data['data']['files'][$parent_file];
|
|
} else {
|
|
// Parent not in manifest (e.g., Laravel framework class), stop here
|
|
$classdata = null;
|
|
}
|
|
} else {
|
|
// No parent, we've reached the root
|
|
$classdata = null;
|
|
}
|
|
} while (!empty($classdata));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load changed PHP files and their dependencies
|
|
*
|
|
* This method loads only the PHP files that have changed and ensures
|
|
* their parent class hierarchies are loaded first.
|
|
*
|
|
* @param array $changed_files Array of changed file paths
|
|
* @return void
|
|
*/
|
|
protected static function __load_changed_php_files(array $changed_files): void
|
|
{
|
|
// Filter to only PHP files
|
|
$trait_files = [];
|
|
$class_files = [];
|
|
|
|
foreach ($changed_files as $file) {
|
|
if (isset(static::$data['data']['files'][$file]['extension']) &&
|
|
static::$data['data']['files'][$file]['extension'] === 'php' &&
|
|
isset(static::$data['data']['files'][$file]['fqcn'])) {
|
|
// Separate traits from classes - traits must be loaded first
|
|
if (isset(static::$data['data']['files'][$file]['is_trait']) &&
|
|
static::$data['data']['files'][$file]['is_trait']) {
|
|
$trait_files[] = $file;
|
|
} else {
|
|
$class_files[] = $file;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load traits first (they have no dependencies and are used by classes)
|
|
foreach ($trait_files as $file) {
|
|
$fqcn = static::$data['data']['files'][$file]['fqcn'];
|
|
static::_load_class_hierarchy($fqcn, static::$data);
|
|
}
|
|
|
|
// Then load classes with their hierarchies
|
|
foreach ($class_files as $file) {
|
|
$fqcn = static::$data['data']['files'][$file]['fqcn'];
|
|
static::_load_class_hierarchy($fqcn, static::$data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract reflection data only for changed files
|
|
* Uses caching to avoid re-extracting unchanged files
|
|
*/
|
|
protected static function __extract_reflection_for_changed_files(array $changed_files): void
|
|
{
|
|
$cache_dir = storage_path('rsx-tmp/persistent/php_reflection');
|
|
|
|
// Ensure cache directory exists
|
|
if (!is_dir($cache_dir)) {
|
|
mkdir($cache_dir, 0755, true);
|
|
}
|
|
|
|
// Build a set of changed files for quick lookup
|
|
$changed_files_set = array_flip($changed_files);
|
|
|
|
// Process ALL PHP files to restore cached data or extract new reflection
|
|
foreach (static::$data['data']['files'] as $file => &$metadata) {
|
|
// Skip non-PHP files
|
|
if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') {
|
|
continue;
|
|
}
|
|
|
|
// Skip files without classes
|
|
if (!isset($metadata['fqcn'])) {
|
|
continue;
|
|
}
|
|
|
|
$fqcn = $metadata['fqcn'];
|
|
$cache_key = $metadata['hash'];
|
|
$cache_file = $cache_dir . '/' . $cache_key . '.json';
|
|
|
|
// Get absolute path - rsx/ files are in project root, not system/
|
|
if (str_starts_with($file, 'rsx/')) {
|
|
$absolute_path = realpath(base_path('../' . $file));
|
|
} else {
|
|
$absolute_path = realpath(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 (resolves symlinks)
|
|
$normalized_file_path = realpath($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 realpath for comparison to handle symlinks correctly
|
|
if (realpath($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;
|
|
|
|
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
|
if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) {
|
|
$has_route = true;
|
|
}
|
|
if ($attr_name === 'Ajax_Endpoint' || str_ends_with($attr_name, '\\Ajax_Endpoint')) {
|
|
$has_ajax_endpoint = true;
|
|
}
|
|
}
|
|
|
|
if ($has_route && $has_ajax_endpoint) {
|
|
$class_name = $metadata['class'] ?? 'Unknown';
|
|
|
|
throw new \RuntimeException(
|
|
"Controller action cannot have both Route and Ajax_Endpoint attributes.\n" .
|
|
"Class: {$class_name}\n" .
|
|
"Method: {$method_name}\n" .
|
|
"File: {$file_path}\n" .
|
|
'A controller action must be either a web route OR an AJAX endpoint, not both.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if metadata represents a controller class
|
|
* @param array $metadata File metadata
|
|
* @return bool True if class extends Rsx_Controller_Abstract
|
|
*/
|
|
protected static function __is_controller_class(array $metadata): bool
|
|
{
|
|
$extends = $metadata['extends'] ?? '';
|
|
|
|
if ($extends === 'Rsx_Controller_Abstract') {
|
|
return true;
|
|
}
|
|
|
|
// Check parent hierarchy
|
|
$current_class = $extends;
|
|
$max_depth = 10;
|
|
|
|
while ($current_class && $max_depth-- > 0) {
|
|
try {
|
|
$parent_metadata = static::php_get_metadata_by_class($current_class);
|
|
if (($parent_metadata['extends'] ?? '') === 'Rsx_Controller_Abstract') {
|
|
return true;
|
|
}
|
|
$current_class = $parent_metadata['extends'] ?? '';
|
|
} catch (\RuntimeException $e) {
|
|
// Check FQCN match
|
|
if ($current_class === 'Rsx_Controller_Abstract' ||
|
|
$current_class === 'App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract') {
|
|
return true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Generate VS Code IDE helper stubs for attributes and class aliases
|
|
*/
|
|
private static function __generate_vscode_stubs(): void
|
|
{
|
|
// Generate to project root (parent of system/)
|
|
$project_root = dirname(base_path());
|
|
$stub_file = $project_root . '/_rsx_helper.php';
|
|
|
|
$output = "<?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;
|
|
}));
|
|
}
|
|
}
|