Add Spa.load_detached_action, decorator identifier rule, npm updates
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\CodeQuality\Rules\JavaScript;
|
||||
|
||||
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||
|
||||
/**
|
||||
* DecoratorIdentifierParamRule - Prohibits identifier (class name) parameters in decorators
|
||||
*
|
||||
* JavaScript decorators cannot reliably use class name references as parameters because
|
||||
* the framework cannot guarantee class definition order in the compiled bundle output.
|
||||
* Class ordering is based on extends hierarchies and other factors, but decorator
|
||||
* parameter dependencies are not tracked.
|
||||
*
|
||||
* Use string literals instead of class references in decorator parameters.
|
||||
*/
|
||||
class DecoratorIdentifierParam_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the unique rule identifier
|
||||
*/
|
||||
public function get_id(): string
|
||||
{
|
||||
return 'JS-DECORATOR-IDENT-01';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable rule name
|
||||
*/
|
||||
public function get_name(): string
|
||||
{
|
||||
return 'Decorator Identifier Parameter';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule description
|
||||
*/
|
||||
public function get_description(): string
|
||||
{
|
||||
return 'Prohibits identifier (class name) references as decorator parameters - use string literals instead';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file patterns this rule applies to
|
||||
*/
|
||||
public function get_file_patterns(): array
|
||||
{
|
||||
return ['*.js'];
|
||||
}
|
||||
|
||||
/**
|
||||
* This rule runs during manifest scan to catch issues early
|
||||
*/
|
||||
public function is_called_during_manifest_scan(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the manifest for decorator identifier parameter violations
|
||||
*/
|
||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||
{
|
||||
static $already_checked = false;
|
||||
|
||||
// Only check once per manifest build
|
||||
if ($already_checked) {
|
||||
return;
|
||||
}
|
||||
$already_checked = true;
|
||||
|
||||
// Get all manifest files
|
||||
$files = \App\RSpade\Core\Manifest\Manifest::get_all();
|
||||
if (empty($files)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check all JavaScript files with decorators
|
||||
foreach ($files as $file => $file_metadata) {
|
||||
// Skip if not a JavaScript file
|
||||
if (($file_metadata['extension'] ?? '') !== 'js') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if no class (non-class JS files)
|
||||
if (!isset($file_metadata['class'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check class-level decorators
|
||||
if (!empty($file_metadata['decorators'])) {
|
||||
$this->check_decorators($file, $file_metadata['class'], $file_metadata['decorators'], 'class');
|
||||
}
|
||||
|
||||
// Check method-level decorators
|
||||
// Method decorators are stored under each method in public_static_methods and public_instance_methods
|
||||
$all_methods = array_merge(
|
||||
$file_metadata['public_static_methods'] ?? [],
|
||||
$file_metadata['public_instance_methods'] ?? []
|
||||
);
|
||||
|
||||
foreach ($all_methods as $method_name => $method_info) {
|
||||
if (!empty($method_info['decorators'])) {
|
||||
$this->check_decorators(
|
||||
$file,
|
||||
$file_metadata['class'],
|
||||
$method_info['decorators'],
|
||||
'method',
|
||||
$method_name,
|
||||
$method_info['line'] ?? 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a set of decorators for identifier parameters
|
||||
*
|
||||
* @param string $file File path
|
||||
* @param string $class_name Class name for context
|
||||
* @param array $decorators Decorators in compact format: [[name, [args]], ...]
|
||||
* @param string $context 'class' or 'method'
|
||||
* @param string|null $method_name Method name if context is 'method'
|
||||
* @param int $line Line number
|
||||
*/
|
||||
private function check_decorators(
|
||||
string $file,
|
||||
string $class_name,
|
||||
array $decorators,
|
||||
string $context,
|
||||
?string $method_name = null,
|
||||
int $line = 1
|
||||
): void {
|
||||
foreach ($decorators as $decorator) {
|
||||
$decorator_name = $decorator[0] ?? 'unknown';
|
||||
$decorator_args = $decorator[1] ?? [];
|
||||
|
||||
// Check each argument for identifier usage
|
||||
$this->check_args_for_identifiers(
|
||||
$file,
|
||||
$class_name,
|
||||
$decorator_name,
|
||||
$decorator_args,
|
||||
$context,
|
||||
$method_name,
|
||||
$line
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively check decorator arguments for identifier values
|
||||
*/
|
||||
private function check_args_for_identifiers(
|
||||
string $file,
|
||||
string $class_name,
|
||||
string $decorator_name,
|
||||
array $args,
|
||||
string $context,
|
||||
?string $method_name,
|
||||
int $line,
|
||||
string $path = ''
|
||||
): void {
|
||||
foreach ($args as $index => $arg) {
|
||||
$current_path = $path ? "{$path}[{$index}]" : "argument {$index}";
|
||||
|
||||
// Check if this argument is an identifier
|
||||
if (is_array($arg) && isset($arg['identifier'])) {
|
||||
$identifier = $arg['identifier'];
|
||||
|
||||
// Build context description
|
||||
if ($context === 'class') {
|
||||
$location = "class '{$class_name}'";
|
||||
} else {
|
||||
$location = "method '{$class_name}::{$method_name}()'";
|
||||
}
|
||||
|
||||
$this->add_violation(
|
||||
$file,
|
||||
$line,
|
||||
"Decorator @{$decorator_name} on {$location} uses identifier '{$identifier}' as a parameter. " .
|
||||
"Class name references are not allowed in decorator parameters because the framework cannot " .
|
||||
"guarantee the referenced class will be defined before this decorator is evaluated.",
|
||||
"@{$decorator_name}(...{$identifier}...)",
|
||||
"Use a string literal instead of the class reference:\n" .
|
||||
" - Change: @{$decorator_name}({$identifier})\n" .
|
||||
" - To: @{$decorator_name}('{$identifier}')\n\n" .
|
||||
"If you need the actual class at runtime, resolve it from the string:\n" .
|
||||
" const cls = Manifest.get_class_by_name('{$identifier}');",
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
|
||||
// Recursively check nested arrays
|
||||
if (is_array($arg) && !isset($arg['identifier'])) {
|
||||
$this->check_args_for_identifiers(
|
||||
$file,
|
||||
$class_name,
|
||||
$decorator_name,
|
||||
$arg,
|
||||
$context,
|
||||
$method_name,
|
||||
$line,
|
||||
$current_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -941,4 +941,75 @@ class Spa {
|
||||
static spa_unknown_route_fatal(path) {
|
||||
console.error(`Unknown route for path ${path} - this shouldn't happen`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an action in detached mode without affecting the live SPA state
|
||||
*
|
||||
* This method resolves a URL to an action, instantiates it on a detached DOM element
|
||||
* (not in the actual document), runs its full lifecycle including on_load(), and
|
||||
* returns the fully-initialized component instance.
|
||||
*
|
||||
* Use cases:
|
||||
* - Getting action metadata (title, breadcrumbs) for navigation UI
|
||||
* - Pre-fetching action data before navigation
|
||||
* - Inspecting action state without displaying it
|
||||
*
|
||||
* IMPORTANT: The caller is responsible for calling action.stop() when done
|
||||
* to prevent memory leaks. The detached action holds references and may have
|
||||
* event listeners that need cleanup.
|
||||
*
|
||||
* @param {string} url - The URL to resolve and load
|
||||
* @param {object} extra_args - Optional extra parameters to pass to the action component.
|
||||
* These are merged with URL-extracted args (extra_args take precedence).
|
||||
* Pass {use_cached_data: true} to have the action load with cached data
|
||||
* without revalidation if cached data is available.
|
||||
* @returns {Promise<Spa_Action|null>} The fully-loaded action instance, or null if route not found
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* const action = await Spa.load_detached_action('/contacts/123');
|
||||
* if (action) {
|
||||
* const title = action.get_title?.() ?? action.constructor.name;
|
||||
* console.log('Page title:', title);
|
||||
* action.stop(); // Clean up when done
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // With cached data (faster, no network request if cached)
|
||||
* const action = await Spa.load_detached_action('/contacts/123', {use_cached_data: true});
|
||||
*/
|
||||
static async load_detached_action(url, extra_args = {}) {
|
||||
// Parse URL and match to route
|
||||
const parsed = Spa.parse_url(url);
|
||||
const url_without_hash = parsed.path + parsed.search;
|
||||
const route_match = Spa.match_url_to_route(url_without_hash);
|
||||
|
||||
if (!route_match) {
|
||||
console_debug('Spa', 'load_detached_action: No route match for ' + url);
|
||||
return null;
|
||||
}
|
||||
|
||||
const action_class = route_match.action_class;
|
||||
const action_name = action_class.name;
|
||||
|
||||
// Merge URL args with extra_args (extra_args take precedence)
|
||||
const args = { ...route_match.args, ...extra_args };
|
||||
|
||||
console_debug('Spa', `load_detached_action: Loading ${action_name} with args:`, args);
|
||||
|
||||
// Create a detached container (not in DOM)
|
||||
const $detached = $('<div>');
|
||||
|
||||
// Instantiate the action on the detached element
|
||||
// This triggers the full component lifecycle: on_create -> render -> on_render -> on_load -> on_ready
|
||||
$detached.component(action_name, args);
|
||||
const action = $detached.component();
|
||||
|
||||
// Wait for the action to be fully ready (including on_load completion)
|
||||
await action.ready();
|
||||
|
||||
console_debug('Spa', `load_detached_action: ${action_name} ready`);
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user