diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 50fec8a6..05db7d50 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.4.14","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":true,"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one","property":"one"}},"declare_strict_types":true},"hashes":{"src\/Framework\/UserAgent\/ValueObjects\/DeviceCategory.php":"ea8bf0dd6f03932e1622b5b2ed5751fe","src\/Framework\/UserAgent\/ParsedUserAgent.php":"65db6417a82fdc55a818ad96f0fb2ed5","src\/Framework\/UserAgent\/UserAgentParser.php":"0ae01d1b91d851c653087cae6f33bc62"}} \ No newline at end of file +{"php":"8.5.0RC3","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":true,"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one","property":"one"}},"declare_strict_types":true},"hashes":{"src\/Framework\/Database\/Seed\/SeedCommand.php":"020de3bf1fad561be6bdbed799d19510","src\/Framework\/Database\/Seed\/SeedRepository.php":"523204a544558a7e11d8c792b2730729","src\/Framework\/Database\/Seed\/Migrations\/CreateSeedsTable.php":"df525e2ee87854f99e79184ba3ab3433","src\/Framework\/Database\/Seed\/SeedServicesInitializer.php":"a492c24e4b1d3c2996292905695f94b7","src\/Framework\/Database\/Seed\/SeedLoader.php":"5c867e0ba10f2fefd6680a948e2e58eb","src\/Framework\/Database\/Seed\/Seeder.php":"9fe694bf7fd34d83b6d3bc74c22e207b","src\/Framework\/Database\/Seed\/SeedRunner.php":"3285f01db3fec92a0493106dd86a7fdb"}} \ No newline at end of file diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 5e8cca2d..804ba1f9 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -159,10 +159,81 @@ function processUser(User $user): UserProfile ``` **Available Value Objects**: -- Core: Email, RGBColor, Url, Hash, Version, Coordinates +- Core: Email, RGBColor, Url, Hash, Version, Coordinates, ClassName, PhpNamespace - HTTP: FlashMessage, ValidationError, RouteParameters - Security: OWASPEventIdentifier, MaskedEmail, ThreatLevel - Performance: Measurement, MetricContext, MemorySummary +- Filesystem: FilePath +- Framework: FrameworkModule, FrameworkModuleRegistry + +## Framework Module System + +Das Framework verwendet ein modulares System, bei dem jeder Top-Level-Ordner in `src/Framework/` als eigenständiges Modul behandelt wird. + +### FrameworkModule Value Object + +Repräsentiert ein einzelnes Framework-Modul: + +```php +use App\Framework\Core\ValueObjects\FrameworkModule; +use App\Framework\Filesystem\ValueObjects\FilePath; + +// Modul erstellen +$basePath = FilePath::create('/var/www/html/src/Framework'); +$httpModule = FrameworkModule::create('Http', $basePath); + +// Namespace-Zugehörigkeit prüfen +$namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth'); +$httpModule->containsNamespace($namespace); // true + +// Klassen-Zugehörigkeit prüfen +$className = ClassName::create('App\\Framework\\Http\\Request'); +$httpModule->containsClass($className); // true + +// Relative Namespace ermitteln +$relative = $httpModule->getRelativeNamespace($namespace); +// Returns: PhpNamespace für 'Middlewares\\Auth' +``` + +### FrameworkModuleRegistry + +Registry aller Framework-Module mit Lookup-Funktionalität: + +```php +use App\Framework\Core\ValueObjects\FrameworkModuleRegistry; + +// Automatische Discovery aller Module +$registry = FrameworkModuleRegistry::discover($frameworkPath); + +// Oder manuell mit variadic constructor +$registry = new FrameworkModuleRegistry( + FrameworkModule::create('Http', $basePath), + FrameworkModule::create('Database', $basePath), + FrameworkModule::create('Cache', $basePath) +); + +// Modul für Namespace finden +$module = $registry->getModuleForNamespace($namespace); + +// Modul für Klasse finden +$module = $registry->getModuleForClass($className); + +// Prüfen ob zwei Klassen im selben Modul liegen +$inSame = $registry->classesInSameModule($classA, $classB); + +// Prüfen ob zwei Namespaces im selben Modul liegen +$inSame = $registry->inSameModule($namespaceA, $namespaceB); + +// Prüfen ob zwei Dateien im selben Modul liegen +$inSame = $registry->filesInSameModule($filePathA, $filePathB); +``` + +### Use Cases + +**Dependency Analysis**: Prüfen ob Abhängigkeiten zwischen Modulen bestehen +**Module Boundaries**: Sicherstellen dass Module-interne Klassen nicht extern verwendet werden +**Circular Dependency Detection**: Erkennen von zirkulären Modul-Abhängigkeiten +**Code Organization**: Validieren dass Klassen im richtigen Modul liegen ## Middleware System diff --git a/src/Application/Admin/ValueObjects/NavigationItem.php b/src/Application/Admin/ValueObjects/NavigationItem.php index 6c5f424c..75e33c82 100644 --- a/src/Application/Admin/ValueObjects/NavigationItem.php +++ b/src/Application/Admin/ValueObjects/NavigationItem.php @@ -44,10 +44,13 @@ final readonly class NavigationItem public function toArray(): array { + $icon = $this->getIcon(); + return [ 'name' => $this->name, 'url' => $this->url, 'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon, + 'icon_html' => $icon?->toHtml('admin-nav__icon') ?? '', 'is_active' => $this->isActive, ]; } diff --git a/src/Application/Admin/ValueObjects/NavigationSection.php b/src/Application/Admin/ValueObjects/NavigationSection.php index fe95b064..57fb0b49 100644 --- a/src/Application/Admin/ValueObjects/NavigationSection.php +++ b/src/Application/Admin/ValueObjects/NavigationSection.php @@ -60,6 +60,8 @@ final readonly class NavigationSection public function toArray(): array { + $icon = $this->getIcon(); + return [ 'section' => $this->name, 'name' => $this->name, @@ -68,6 +70,7 @@ final readonly class NavigationSection $this->items ), 'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon, + 'icon_html' => $icon?->toHtml('admin-nav__section-icon') ?? '', ]; } diff --git a/src/Framework/Console/MethodSignatureAnalyzer.php b/src/Framework/Console/MethodSignatureAnalyzer.php index 3d66215e..d77acc18 100644 --- a/src/Framework/Console/MethodSignatureAnalyzer.php +++ b/src/Framework/Console/MethodSignatureAnalyzer.php @@ -330,8 +330,9 @@ final readonly class MethodSignatureAnalyzer $returnType = $method->getReturnType(); if ($returnType instanceof ReflectionNamedType) { $returnTypeName = $returnType->getName(); - // Accept: int, ExitCode, ActionResult, or array + // Accept: void, int, ExitCode, ActionResult, or array $validReturnTypes = [ + 'void', 'int', ExitCode::class, 'App\Framework\MagicLinks\Actions\ActionResult', diff --git a/src/Framework/Core/ValueObjects/FrameworkModule.php b/src/Framework/Core/ValueObjects/FrameworkModule.php new file mode 100644 index 00000000..f836ca4a --- /dev/null +++ b/src/Framework/Core/ValueObjects/FrameworkModule.php @@ -0,0 +1,116 @@ +namespace = PhpNamespace::fromString(self::FRAMEWORK_NAMESPACE_PREFIX . $name); + } + + /** + * Create module from name and base path + */ + public static function create(string $name, FilePath $frameworkBasePath): self + { + $modulePath = $frameworkBasePath->join($name); + + return new self($name, $modulePath); + } + + /** + * Check if a namespace belongs to this module + */ + public function containsNamespace(PhpNamespace $namespace): bool + { + return $namespace->startsWith($this->namespace); + } + + /** + * Check if a class belongs to this module + */ + public function containsClass(ClassName $className): bool + { + return $this->containsNamespace($className->getNamespaceObject()); + } + + /** + * Check if a file path belongs to this module + */ + public function containsFile(FilePath $filePath): bool + { + return $this->path->contains($filePath); + } + + /** + * Get the relative namespace within this module + * + * Example: For Http module and namespace App\Framework\Http\Middlewares\Auth + * Returns: Middlewares\Auth + */ + public function getRelativeNamespace(PhpNamespace $namespace): ?PhpNamespace + { + if (! $this->containsNamespace($namespace)) { + return null; + } + + $moduleParts = $this->namespace->parts(); + $namespaceParts = $namespace->parts(); + + // Remove module prefix parts + $relativeParts = array_slice($namespaceParts, count($moduleParts)); + + if (empty($relativeParts)) { + return PhpNamespace::global(); + } + + return PhpNamespace::fromParts($relativeParts); + } + + /** + * Compare for equality + */ + public function equals(self $other): bool + { + return $this->name === $other->name; + } + + public function toString(): string + { + return $this->name; + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php b/src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php new file mode 100644 index 00000000..c7c21f6c --- /dev/null +++ b/src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php @@ -0,0 +1,208 @@ + Module name => Module */ + private array $modules; + + public function __construct(FrameworkModule ...$modules) + { + $indexed = []; + + foreach ($modules as $module) { + $indexed[$module->name] = $module; + } + + $this->modules = $indexed; + } + + /** + * Create registry by scanning Framework directory + */ + public static function discover(FilePath $frameworkPath): self + { + if (! $frameworkPath->isDirectory()) { + throw new InvalidArgumentException( + "Framework path does not exist or is not a directory: {$frameworkPath}" + ); + } + + $modules = []; + $entries = scandir($frameworkPath->toString()); + + if ($entries === false) { + throw new InvalidArgumentException( + "Cannot read Framework directory: {$frameworkPath}" + ); + } + + foreach ($entries as $entry) { + // Skip hidden files and special entries + if ($entry === '.' || $entry === '..' || str_starts_with($entry, '.')) { + continue; + } + + $entryPath = $frameworkPath->join($entry); + + // Only consider directories as modules + if (! $entryPath->isDirectory()) { + continue; + } + + // Module names must be PascalCase + if (! preg_match('/^[A-Z][a-zA-Z0-9]*$/', $entry)) { + continue; + } + + $modules[] = FrameworkModule::create($entry, $frameworkPath); + } + + return new self(...$modules); + } + + /** + * Get module for a PHP namespace + */ + public function getModuleForNamespace(PhpNamespace $namespace): ?FrameworkModule + { + $namespaceStr = $namespace->toString(); + + // Must be a Framework namespace + if (! str_starts_with($namespaceStr, self::FRAMEWORK_NAMESPACE_PREFIX)) { + return null; + } + + // Extract module name (first part after App\Framework\) + $afterPrefix = substr($namespaceStr, strlen(self::FRAMEWORK_NAMESPACE_PREFIX)); + $parts = explode('\\', $afterPrefix); + $moduleName = $parts[0] ?? null; + + if ($moduleName === null || $moduleName === '') { + return null; + } + + return $this->modules[$moduleName] ?? null; + } + + /** + * Get module for a class name + */ + public function getModuleForClass(ClassName $className): ?FrameworkModule + { + return $this->getModuleForNamespace($className->getNamespaceObject()); + } + + /** + * Get module for a file path + */ + public function getModuleForFile(FilePath $filePath): ?FrameworkModule + { + foreach ($this->modules as $module) { + if ($module->containsFile($filePath)) { + return $module; + } + } + + return null; + } + + /** + * Check if two namespaces belong to the same module + */ + public function inSameModule(PhpNamespace $a, PhpNamespace $b): bool + { + $moduleA = $this->getModuleForNamespace($a); + $moduleB = $this->getModuleForNamespace($b); + + if ($moduleA === null || $moduleB === null) { + return false; + } + + return $moduleA->equals($moduleB); + } + + /** + * Check if two classes belong to the same module + */ + public function classesInSameModule(ClassName $a, ClassName $b): bool + { + return $this->inSameModule( + $a->getNamespaceObject(), + $b->getNamespaceObject() + ); + } + + /** + * Check if two files belong to the same module + */ + public function filesInSameModule(FilePath $a, FilePath $b): bool + { + $moduleA = $this->getModuleForFile($a); + $moduleB = $this->getModuleForFile($b); + + if ($moduleA === null || $moduleB === null) { + return false; + } + + return $moduleA->equals($moduleB); + } + + /** + * Get a specific module by name + */ + public function getModule(string $name): ?FrameworkModule + { + return $this->modules[$name] ?? null; + } + + /** + * Check if a module exists + */ + public function hasModule(string $name): bool + { + return isset($this->modules[$name]); + } + + /** + * Get all modules + * + * @return array + */ + public function getAllModules(): array + { + return $this->modules; + } + + /** + * Get all module names + * + * @return array + */ + public function getModuleNames(): array + { + return array_keys($this->modules); + } + + /** + * Get module count + */ + public function count(): int + { + return count($this->modules); + } +} diff --git a/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarning.php b/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarning.php index 0f9e7e39..e6e7a265 100644 --- a/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarning.php +++ b/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarning.php @@ -20,4 +20,3 @@ final readonly class DiscoveryWarning ) { } } - diff --git a/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningAggregator.php b/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningAggregator.php index f179159b..a85a0bcf 100644 --- a/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningAggregator.php +++ b/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningAggregator.php @@ -101,4 +101,3 @@ final class DiscoveryWarningAggregator return $this->warningsByFile !== []; } } - diff --git a/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningGroup.php b/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningGroup.php index f079ba48..888e0303 100644 --- a/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningGroup.php +++ b/src/Framework/ExceptionHandling/Aggregation/DiscoveryWarningGroup.php @@ -25,4 +25,3 @@ final readonly class DiscoveryWarningGroup return count($this->warnings); } } - diff --git a/src/Framework/ExceptionHandling/Audit/ExceptionAuditLogger.php b/src/Framework/ExceptionHandling/Audit/ExceptionAuditLogger.php index 539e93a5..8f5feebf 100644 --- a/src/Framework/ExceptionHandling/Audit/ExceptionAuditLogger.php +++ b/src/Framework/ExceptionHandling/Audit/ExceptionAuditLogger.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Framework\ExceptionHandling\Audit; use App\Framework\Audit\AuditLogger; -use App\Framework\Audit\ValueObjects\AuditEntry; use App\Framework\Audit\ValueObjects\AuditableAction; +use App\Framework\Audit\ValueObjects\AuditEntry; use App\Framework\DateTime\Clock; use App\Framework\ExceptionHandling\Context\ExceptionContextData; use App\Framework\ExceptionHandling\Context\ExceptionContextProvider; @@ -44,7 +44,7 @@ final readonly class ExceptionAuditLogger $context = $context ?? $this->getContext($exception); // Skip if not auditable - if (!$this->isAuditable($context)) { + if (! $this->isAuditable($context)) { return; } @@ -192,6 +192,7 @@ final readonly class ExceptionAuditLogger // Remove common suffixes $component = $context->component; $component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component); + return strtolower($component); } @@ -246,7 +247,7 @@ final readonly class ExceptionAuditLogger ]; // Add context data - if (!empty($context->data)) { + if (! empty($context->data)) { $metadata['context_data'] = $context->data; } @@ -267,7 +268,7 @@ final readonly class ExceptionAuditLogger } // Add tags - if (!empty($context->tags)) { + if (! empty($context->tags)) { $metadata['tags'] = $context->tags; } @@ -283,7 +284,7 @@ final readonly class ExceptionAuditLogger // Merge with existing metadata (but exclude internal fields) $excludeKeys = ['auditable', 'audit_action', 'entity_type']; foreach ($context->metadata as $key => $value) { - if (!in_array($key, $excludeKeys, true)) { + if (! in_array($key, $excludeKeys, true)) { $metadata[$key] = $value; } } @@ -306,4 +307,3 @@ final readonly class ExceptionAuditLogger return $this->contextProvider->get($exception); } } - diff --git a/src/Framework/ExceptionHandling/BasicErrorHandler.php b/src/Framework/ExceptionHandling/BasicErrorHandler.php index 56ad6ab8..7bace129 100644 --- a/src/Framework/ExceptionHandling/BasicErrorHandler.php +++ b/src/Framework/ExceptionHandling/BasicErrorHandler.php @@ -1,4 +1,5 @@ mergeContexts($cached, $baseContext); } + return $cached; } } @@ -109,6 +110,7 @@ final readonly class ExceptionContextBuilder // Extract session ID if (property_exists($request, 'session') && $request->session !== null) { $sessionId = $request->session->id->toString(); + try { $context = $context->withSessionId(SessionId::fromString($sessionId)); } catch (\InvalidArgumentException) { @@ -162,13 +164,13 @@ final readonly class ExceptionContextBuilder if ($base->component !== null && $merged->component === null) { $merged = $merged->withOperation($merged->operation ?? '', $base->component); } - if (!empty($base->data)) { + if (! empty($base->data)) { $merged = $merged->addData($base->data); } - if (!empty($base->debug)) { + if (! empty($base->debug)) { $merged = $merged->addDebug($base->debug); } - if (!empty($base->metadata)) { + if (! empty($base->metadata)) { $merged = $merged->addMetadata($base->metadata); } if ($base->userId !== null) { @@ -186,7 +188,7 @@ final readonly class ExceptionContextBuilder if ($base->userAgent !== null) { $merged = $merged->withUserAgent($base->userAgent); } - if (!empty($base->tags)) { + if (! empty($base->tags)) { $merged = $merged->withTags(...array_merge($merged->tags, $base->tags)); } @@ -261,7 +263,7 @@ final readonly class ExceptionContextBuilder ]); // Add scope tags - if (!empty($scopeContext->tags)) { + if (! empty($scopeContext->tags)) { $context = $context->withTags(...$scopeContext->tags); } @@ -299,4 +301,3 @@ final readonly class ExceptionContextBuilder return null; } } - diff --git a/src/Framework/ExceptionHandling/Context/ExceptionContextCache.php b/src/Framework/ExceptionHandling/Context/ExceptionContextCache.php index 18e8890a..6fad0aa5 100644 --- a/src/Framework/ExceptionHandling/Context/ExceptionContextCache.php +++ b/src/Framework/ExceptionHandling/Context/ExceptionContextCache.php @@ -178,7 +178,7 @@ final readonly class ExceptionContextCache $result = $this->cache->get($cacheKey); $item = $result->getItem($cacheKey); - if (!$item->isHit) { + if (! $item->isHit) { return null; } @@ -210,4 +210,3 @@ final readonly class ExceptionContextCache $this->cache->set($cacheItem); } } - diff --git a/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelation.php b/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelation.php index 5ba07282..51c87ea4 100644 --- a/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelation.php +++ b/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelation.php @@ -37,4 +37,3 @@ final readonly class ExceptionCorrelation ]; } } - diff --git a/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelationEngine.php b/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelationEngine.php index 53de3d7d..d5e3659b 100644 --- a/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelationEngine.php +++ b/src/Framework/ExceptionHandling/Correlation/ExceptionCorrelationEngine.php @@ -75,11 +75,13 @@ final readonly class ExceptionCorrelationEngine // Prefer Request-ID, then Session-ID, then User-ID if ($context->requestId !== null) { $requestId = is_string($context->requestId) ? $context->requestId : $context->requestId->toString(); + return 'request:' . $requestId; } if ($context->sessionId !== null) { $sessionId = is_string($context->sessionId) ? $context->sessionId : $context->sessionId->toString(); + return 'session:' . $sessionId; } @@ -98,7 +100,7 @@ final readonly class ExceptionCorrelationEngine $result = $this->cache->get($cacheKey); $item = $result->getItem($cacheKey); - if (!$item->isHit) { + if (! $item->isHit) { return new ExceptionCorrelation(correlationKey: ''); } @@ -128,4 +130,3 @@ final readonly class ExceptionCorrelationEngine $this->cache->set($cacheItem); } } - diff --git a/src/Framework/ExceptionHandling/ErrorContext.php b/src/Framework/ExceptionHandling/ErrorContext.php index a1317cd9..c57c7c2e 100644 --- a/src/Framework/ExceptionHandling/ErrorContext.php +++ b/src/Framework/ExceptionHandling/ErrorContext.php @@ -1,10 +1,10 @@ rateLimiter?->shouldSkipAudit($e, $exceptionContext) ?? false; // Log exception to audit system if auditable and not rate limited - if ($this->auditLogger !== null && !$shouldSkipAudit) { + if ($this->auditLogger !== null && ! $shouldSkipAudit) { $this->auditLogger->logIfAuditable($e, $exceptionContext); } // Log exception if not rate limited and reporter is available - if (!$shouldSkipLogging && $this->reporter !== null) { + if (! $shouldSkipLogging && $this->reporter !== null) { $this->reporter->report($e); } @@ -85,6 +85,7 @@ final readonly class ErrorKernel // Handle based on context if ($executionContext->isCli()) { $this->handleCliException($e); + return null; } @@ -190,7 +191,7 @@ final readonly class ErrorKernel ); // Ensure we return HttpResponse (type safety) - if (!$result instanceof HttpResponse) { + if (! $result instanceof HttpResponse) { throw new \RuntimeException('HTTP renderer must return HttpResponse'); } @@ -221,4 +222,3 @@ final readonly class ErrorKernel }; } } - diff --git a/src/Framework/ExceptionHandling/ErrorRendererFactory.php b/src/Framework/ExceptionHandling/ErrorRendererFactory.php index deab618d..15a96d51 100644 --- a/src/Framework/ExceptionHandling/ErrorRendererFactory.php +++ b/src/Framework/ExceptionHandling/ErrorRendererFactory.php @@ -1,4 +1,5 @@ executionContext->isCli()) { // ConsoleOutput should always be available in CLI context $output = $this->consoleOutput ?? new ConsoleOutput(); + return new ConsoleErrorRenderer($output); } @@ -53,6 +55,7 @@ final readonly class ErrorRendererFactory public function createHttpRenderer(?bool $debugMode = null): ResponseErrorRenderer { $debugMode = $debugMode ?? $this->config->debugMode; + return new ResponseErrorRenderer($this->engine, $debugMode); } diff --git a/src/Framework/ExceptionHandling/ErrorScopeContext.php b/src/Framework/ExceptionHandling/ErrorScopeContext.php index f8069e10..3be6c056 100644 --- a/src/Framework/ExceptionHandling/ErrorScopeContext.php +++ b/src/Framework/ExceptionHandling/ErrorScopeContext.php @@ -1,9 +1,9 @@ bind(ErrorHandler::class, new ErrorHandler(strategy: $strategy)); // ExceptionContextProvider - bind as singleton if not already bound - if (!$container->has(ExceptionContextProvider::class)) { + if (! $container->has(ExceptionContextProvider::class)) { $container->singleton(ExceptionContextProvider::class, new ExceptionContextProvider()); } @@ -148,7 +148,7 @@ final readonly class ExceptionHandlingInitializer private function registerManager(Container $container, ExceptionHandlerManager $manager): void { // Store manager in container for potential later use - if (!$container->has(ExceptionHandlerManager::class)) { + if (! $container->has(ExceptionHandlerManager::class)) { $container->instance(ExceptionHandlerManager::class, $manager); } } diff --git a/src/Framework/ExceptionHandling/Factory/ExceptionFactory.php b/src/Framework/ExceptionHandling/Factory/ExceptionFactory.php index 06ea577d..2bba198c 100644 --- a/src/Framework/ExceptionHandling/Factory/ExceptionFactory.php +++ b/src/Framework/ExceptionHandling/Factory/ExceptionFactory.php @@ -25,7 +25,8 @@ final readonly class ExceptionFactory public function __construct( private ExceptionContextProvider $contextProvider, private ErrorScope $errorScope - ) {} + ) { + } /** * Create exception with context @@ -300,7 +301,7 @@ final readonly class ExceptionFactory } // Add scope tags - if (!empty($scopeContext->tags)) { + if (! empty($scopeContext->tags)) { $enriched = $enriched->withTags(...$scopeContext->tags); } diff --git a/src/Framework/ExceptionHandling/FatalErrorTypes.php b/src/Framework/ExceptionHandling/FatalErrorTypes.php index c76b0e98..dfae4362 100644 --- a/src/Framework/ExceptionHandling/FatalErrorTypes.php +++ b/src/Framework/ExceptionHandling/FatalErrorTypes.php @@ -1,4 +1,5 @@ getMetricValue($cacheKey); } @@ -111,11 +112,12 @@ final readonly class ExceptionMetricsCollector $result = $this->cache->get($cacheKey); $item = $result->getItem($cacheKey); - if (!$item->isHit) { + if (! $item->isHit) { return 0; } $value = $item->value; + return is_int($value) ? $value : 0; } @@ -163,7 +165,7 @@ final readonly class ExceptionMetricsCollector $result = $this->cache->get($cacheKey); $item = $result->getItem($cacheKey); - if (!$item->isHit || !is_array($item->value)) { + if (! $item->isHit || ! is_array($item->value)) { return 0.0; } @@ -175,4 +177,3 @@ final readonly class ExceptionMetricsCollector return array_sum($times) / count($times); } } - diff --git a/src/Framework/ExceptionHandling/Metrics/PrometheusExporter.php b/src/Framework/ExceptionHandling/Metrics/PrometheusExporter.php index 5e6032de..a16b297d 100644 --- a/src/Framework/ExceptionHandling/Metrics/PrometheusExporter.php +++ b/src/Framework/ExceptionHandling/Metrics/PrometheusExporter.php @@ -63,7 +63,7 @@ final readonly class PrometheusExporter { // Replace invalid characters $sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name); + return $sanitized; } } - diff --git a/src/Framework/ExceptionHandling/Middleware/ErrorScopeMiddleware.php b/src/Framework/ExceptionHandling/Middleware/ErrorScopeMiddleware.php index 776fdb13..5de2259b 100644 --- a/src/Framework/ExceptionHandling/Middleware/ErrorScopeMiddleware.php +++ b/src/Framework/ExceptionHandling/Middleware/ErrorScopeMiddleware.php @@ -180,4 +180,3 @@ final readonly class ErrorScopeMiddleware implements HttpMiddleware ]; } } - diff --git a/src/Framework/ExceptionHandling/PatternDetection/ExceptionPattern.php b/src/Framework/ExceptionHandling/PatternDetection/ExceptionPattern.php index 7b1a88a5..1522ad86 100644 --- a/src/Framework/ExceptionHandling/PatternDetection/ExceptionPattern.php +++ b/src/Framework/ExceptionHandling/PatternDetection/ExceptionPattern.php @@ -36,11 +36,10 @@ final readonly class ExceptionPattern 'fingerprint' => $this->fingerprint, 'description' => $this->description, 'fix_suggestions' => array_map( - fn(FixSuggestion $suggestion) => $suggestion->toArray(), + fn (FixSuggestion $suggestion) => $suggestion->toArray(), $this->fixSuggestions ), 'occurrence_count' => $this->occurrenceCount, ]; } } - diff --git a/src/Framework/ExceptionHandling/PatternDetection/ExceptionPatternDetector.php b/src/Framework/ExceptionHandling/PatternDetection/ExceptionPatternDetector.php index 1fe74d35..4a3aa307 100644 --- a/src/Framework/ExceptionHandling/PatternDetection/ExceptionPatternDetector.php +++ b/src/Framework/ExceptionHandling/PatternDetection/ExceptionPatternDetector.php @@ -47,7 +47,7 @@ final readonly class ExceptionPatternDetector if (isset($this->knowledgeBase[$exceptionClass])) { $patternData = $this->knowledgeBase[$exceptionClass]; $fixSuggestions = array_map( - fn(array $fix) => new FixSuggestion( + fn (array $fix) => new FixSuggestion( title: $fix['title'] ?? '', description: $fix['description'] ?? '', codeExample: $fix['code'] ?? null, @@ -66,4 +66,3 @@ final readonly class ExceptionPatternDetector return $patterns; } } - diff --git a/src/Framework/ExceptionHandling/PatternDetection/FixSuggestion.php b/src/Framework/ExceptionHandling/PatternDetection/FixSuggestion.php index 6f03983f..73c97da6 100644 --- a/src/Framework/ExceptionHandling/PatternDetection/FixSuggestion.php +++ b/src/Framework/ExceptionHandling/PatternDetection/FixSuggestion.php @@ -40,4 +40,3 @@ final readonly class FixSuggestion ]; } } - diff --git a/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceMetrics.php b/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceMetrics.php index 272daeba..598b757e 100644 --- a/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceMetrics.php +++ b/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceMetrics.php @@ -51,4 +51,3 @@ final readonly class ExceptionPerformanceMetrics ]; } } - diff --git a/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceTracker.php b/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceTracker.php index a2435bdd..97df039d 100644 --- a/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceTracker.php +++ b/src/Framework/ExceptionHandling/Performance/ExceptionPerformanceTracker.php @@ -108,4 +108,3 @@ final readonly class ExceptionPerformanceTracker return null; } } - diff --git a/src/Framework/ExceptionHandling/RateLimit/ExceptionFingerprint.php b/src/Framework/ExceptionHandling/RateLimit/ExceptionFingerprint.php index 94006de5..14ea29eb 100644 --- a/src/Framework/ExceptionHandling/RateLimit/ExceptionFingerprint.php +++ b/src/Framework/ExceptionHandling/RateLimit/ExceptionFingerprint.php @@ -143,4 +143,3 @@ final readonly class ExceptionFingerprint return $this->hash; } } - diff --git a/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimitConfig.php b/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimitConfig.php index 8952d606..b50a444a 100644 --- a/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimitConfig.php +++ b/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimitConfig.php @@ -106,4 +106,3 @@ final readonly class ExceptionRateLimitConfig ); } } - diff --git a/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimiter.php b/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimiter.php index 07071b08..5e003285 100644 --- a/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimiter.php +++ b/src/Framework/ExceptionHandling/RateLimit/ExceptionRateLimiter.php @@ -7,7 +7,6 @@ namespace App\Framework\ExceptionHandling\RateLimit; use App\Framework\Cache\Cache; use App\Framework\Cache\CacheItem; use App\Framework\Cache\CacheKey; -use App\Framework\Core\ValueObjects\Duration; use App\Framework\ExceptionHandling\Context\ExceptionContextData; use Throwable; @@ -39,7 +38,7 @@ final readonly class ExceptionRateLimiter */ public function shouldProcess(Throwable $exception, ?ExceptionContextData $context = null): bool { - if (!$this->config->enabled) { + if (! $this->config->enabled) { return true; // Rate limiting disabled, always process } @@ -70,11 +69,11 @@ final readonly class ExceptionRateLimiter */ public function shouldSkipLogging(Throwable $exception, ?ExceptionContextData $context = null): bool { - if (!$this->config->enabled || !$this->config->skipLoggingOnLimit) { + if (! $this->config->enabled || ! $this->config->skipLoggingOnLimit) { return false; } - return !$this->shouldProcess($exception, $context); + return ! $this->shouldProcess($exception, $context); } /** @@ -82,11 +81,11 @@ final readonly class ExceptionRateLimiter */ public function shouldSkipAudit(Throwable $exception, ?ExceptionContextData $context = null): bool { - if (!$this->config->enabled || !$this->config->skipAuditOnLimit) { + if (! $this->config->enabled || ! $this->config->skipAuditOnLimit) { return false; } - return !$this->shouldProcess($exception, $context); + return ! $this->shouldProcess($exception, $context); } /** @@ -94,7 +93,7 @@ final readonly class ExceptionRateLimiter */ public function shouldTrackMetrics(Throwable $exception, ?ExceptionContextData $context = null): bool { - if (!$this->config->enabled || !$this->config->trackMetricsOnLimit) { + if (! $this->config->enabled || ! $this->config->trackMetricsOnLimit) { return true; // Always track if not enabled or tracking not disabled } @@ -116,6 +115,7 @@ final readonly class ExceptionRateLimiter : ExceptionFingerprint::fromException($exception); $cacheKey = $this->buildCacheKey($fingerprint); + return $this->getCachedCount($cacheKey); } @@ -135,11 +135,12 @@ final readonly class ExceptionRateLimiter $result = $this->cache->get($cacheKey); $item = $result->getItem($cacheKey); - if (!$item->isHit) { + if (! $item->isHit) { return 0; } $value = $item->value; + return is_int($value) ? $value : 0; } @@ -179,4 +180,3 @@ final readonly class ExceptionRateLimiter $this->cache->forget($cacheKey); } } - diff --git a/src/Framework/ExceptionHandling/Recovery/ExceptionRecoveryManager.php b/src/Framework/ExceptionHandling/Recovery/ExceptionRecoveryManager.php index 0636cfb0..e4537f53 100644 --- a/src/Framework/ExceptionHandling/Recovery/ExceptionRecoveryManager.php +++ b/src/Framework/ExceptionHandling/Recovery/ExceptionRecoveryManager.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Framework\ExceptionHandling\Recovery; use App\Framework\Exception\ExceptionMetadata; -use App\Framework\ExceptionHandling\Context\ExceptionContextData; use Throwable; /** @@ -34,7 +33,7 @@ final readonly class ExceptionRecoveryManager } // Check if exception is retryable (implements marker interface or is in whitelist) - if (!$this->isRetryable($exception)) { + if (! $this->isRetryable($exception)) { return false; } @@ -102,4 +101,3 @@ final readonly class ExceptionRecoveryManager interface RetryableException { } - diff --git a/src/Framework/ExceptionHandling/Recovery/RetryStrategy.php b/src/Framework/ExceptionHandling/Recovery/RetryStrategy.php index 2bf89b64..b81cef7d 100644 --- a/src/Framework/ExceptionHandling/Recovery/RetryStrategy.php +++ b/src/Framework/ExceptionHandling/Recovery/RetryStrategy.php @@ -31,4 +31,3 @@ enum RetryStrategy: string }; } } - diff --git a/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php b/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php index aa2de668..7e3968a2 100644 --- a/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php +++ b/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php @@ -20,7 +20,8 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer { public function __construct( private ConsoleOutput $output - ) {} + ) { + } public function canRender(\Throwable $exception): bool { @@ -107,13 +108,13 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer */ private function formatFileLink(string $filePath, int $line): string { - if (!$this->isPhpStorm()) { + if (! $this->isPhpStorm()) { return $filePath . ':' . $line; } $linkFormatter = $this->output->getLinkFormatter(); $relativePath = PhpStormDetector::getRelativePath($filePath); - + return $linkFormatter->createFileLinkWithLine($filePath, $line, $relativePath . ':' . $line); } @@ -124,9 +125,9 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer { $indexLabel = str_pad((string) $index, $indexWidth, ' ', STR_PAD_LEFT); $baseFormat = sprintf('#%s %s', $indexLabel, $item->formatForDisplay()); - + // If PhpStorm is detected, replace file:line with clickable link - if (!$this->isPhpStorm()) { + if (! $this->isPhpStorm()) { return $baseFormat; } @@ -135,12 +136,12 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer $linkFormatter = $this->output->getLinkFormatter(); $relativePath = PhpStormDetector::getRelativePath($item->file); $fileLink = $linkFormatter->createFileLinkWithLine($item->file, $item->line, $relativePath . ':' . $item->line); - + // Replace the file:line part in the formatted string // Use the short file path that's actually in the formatted string $shortFile = $item->getShortFile(); $fileLocation = $shortFile . ':' . $item->line; - + // Find and replace the file location in the base format $position = strpos($baseFormat, $fileLocation); if ($position !== false) { @@ -151,4 +152,3 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer return $baseFormat; } } - diff --git a/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php b/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php index 52f482de..a1c3a6b8 100644 --- a/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php +++ b/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php @@ -29,7 +29,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer private Engine $engine, private bool $isDebugMode = false, private ?ExceptionMessageTranslator $messageTranslator = null - ) {} + ) { + } /** * Check if this renderer can handle the exception @@ -73,7 +74,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer // Get user-friendly message if translator is available $context = $contextProvider?->get($exception); - $userMessage = $this->messageTranslator?->translate($exception, $context) + $userMessage = $this->messageTranslator?->translate($exception, $context) ?? new \App\Framework\ExceptionHandling\Translation\UserFriendlyMessage( message: $this->isDebugMode ? $exception->getMessage() @@ -85,7 +86,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer 'message' => $userMessage->message, 'type' => $this->isDebugMode ? $this->getShortClassName(get_class($exception)) : 'ServerError', 'code' => $exception->getCode(), - ] + ], ]; if ($userMessage->title !== null) { @@ -136,13 +137,14 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer ?ExceptionContextProvider $contextProvider ): HttpResponse { $statusCode = $this->getHttpStatusCode($exception); + $templateError = null; // Try to render using template system - $html = $this->renderWithTemplate($exception, $contextProvider, $statusCode); + $html = $this->renderWithTemplate($exception, $contextProvider, $statusCode, $templateError); // Fallback to simple HTML if template rendering fails if ($html === null) { - $html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode); + $html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode, $templateError); } return new HttpResponse( @@ -163,7 +165,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer private function renderWithTemplate( \Throwable $exception, ?ExceptionContextProvider $contextProvider, - int $statusCode + int $statusCode, + ?string &$templateError = null ): ?string { try { // Determine template name based on status code @@ -194,6 +197,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer )); // Return null to trigger fallback HTML generation + $templateError = $e->getMessage(); return null; } } @@ -229,6 +233,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer : 'An error occurred while processing your request.', 'exceptionClass' => $this->getShortClassName(get_class($exception)), 'isDebugMode' => $this->isDebugMode, + 'requestMethod' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'requestUri' => $_SERVER['REQUEST_URI'] ?? '/', ]; // Add debug information if enabled @@ -240,8 +246,18 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer 'trace' => $stackTrace->formatForHtml(), 'trace_short' => $stackTrace->formatShort(), 'trace_frames' => $stackTrace->toArray(), + 'trace_plain' => $stackTrace->formatForConsole(), ]; + // Add pre-rendered HTML for stack trace list (collapsible frames) + $data['stackTraceHtml'] = $this->renderStackTraceList($stackTrace); + + // Add syntax-highlighted code snippet for exception location + $data['codeSnippet'] = $this->getCodeSnippet( + $exception->getFile(), + $exception->getLine() + ); + // Add context from WeakMap if available if ($contextProvider !== null) { $context = $contextProvider->get($exception); @@ -265,7 +281,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer private function generateFallbackHtml( \Throwable $exception, ?ExceptionContextProvider $contextProvider, - int $statusCode + int $statusCode, + ?string $templateError = null ): string { // HTML-encode all variables for security $title = htmlspecialchars($this->getErrorTitle($statusCode), ENT_QUOTES | ENT_HTML5, 'UTF-8'); @@ -276,6 +293,15 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + $exceptionClass = htmlspecialchars($this->getShortClassName(get_class($exception)), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $requestMethod = htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? 'GET', ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $templateNotice = $templateError !== null + ? '
Template fallback: ' . htmlspecialchars($templateError, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '
' + : ''; + $debugBadge = $this->isDebugMode + ? 'DEBUG' + : 'PROD'; $debugInfo = ''; if ($this->isDebugMode) { @@ -294,20 +320,39 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: #333; - max-width: 800px; + max-width: 900px; margin: 0 auto; padding: 2rem; background: #f5f5f5; } .error-container { background: white; - border-radius: 8px; + border-radius: 10px; padding: 2rem; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + box-shadow: 0 2px 10px rgba(0,0,0,0.08); } h1 { color: #d32f2f; margin-top: 0; + display: flex; + align-items: center; + gap: 0.5rem; + } + .badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.8rem; + letter-spacing: 0.02em; + border: 1px solid rgba(0,0,0,0.08); + } + .badge--debug { + background: #e8f5e9; + color: #1b5e20; + } + .badge--prod { + background: #fff3cd; + color: #8a6d3b; } .error-message { background: #fff3cd; @@ -315,6 +360,36 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer padding: 1rem; margin: 1rem 0; } + .notice { + padding: 0.75rem 1rem; + border-radius: 6px; + border: 1px solid #e1e3e6; + background: #f9fafb; + color: #555; + margin: 1rem 0; + } + .notice--warn { + border-color: #ffe599; + background: #fffaf0; + } + .meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; + margin: 1rem 0; + } + .meta-card { + padding: 0.75rem 1rem; + border: 1px solid #e1e3e6; + border-radius: 6px; + background: #fafbfc; + } + .meta-label { + display: block; + font-size: 0.8rem; + color: #666; + margin-bottom: 0.25rem; + } .debug-info { background: #f8f9fa; border: 1px solid #dee2e6; @@ -399,10 +474,25 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
-

{$title}

+

{$title} {$debugBadge}

+ {$templateNotice}

{$message}

+
+
+ Status + {$statusCode} +
+
+ Exception + {$exceptionClass} +
+
+ Request + {$requestMethod} {$requestUri} +
+
{$debugInfo}
@@ -478,7 +568,7 @@ HTML;
{$renderedFrames}
-
{$trace}
+ @@ -582,6 +672,7 @@ HTML; private function getShortClassName(string $fullClassName): string { $parts = explode('\\', $fullClassName); + return end($parts); } @@ -590,7 +681,7 @@ HTML; */ private function getCodeSnippet(string $file, int $line): string { - if (!file_exists($file)) { + if (! file_exists($file)) { return ''; } @@ -617,17 +708,15 @@ HTML; $callEscaped = htmlspecialchars($call, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $locationEscaped = htmlspecialchars($item->getShortFile() . ':' . $item->line, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $isVendor = $item->isVendorFrame(); - $expanded = !$isVendor && $index < 3; $codeSnippet = ''; - if (!$isVendor && $index < 3) { + if (! $isVendor && $index < 3) { $codeSnippet = $this->getCodeSnippet($item->file, $item->line); } $frames[] = sprintf( - '
#%d %s %s%s
', + '
#%d %s %s%s
', $isVendor ? ' stack-frame--vendor' : '', - $expanded ? ' open' : '', $index, $callEscaped, $locationEscaped, diff --git a/src/Framework/ExceptionHandling/Reporter/LogReporter.php b/src/Framework/ExceptionHandling/Reporter/LogReporter.php index c73514bb..c4ccc153 100644 --- a/src/Framework/ExceptionHandling/Reporter/LogReporter.php +++ b/src/Framework/ExceptionHandling/Reporter/LogReporter.php @@ -1,4 +1,5 @@ fiberId(); - if (!isset($this->stack[$id])) { + if (! isset($this->stack[$id])) { return; } @@ -59,7 +59,7 @@ final class ErrorScope array_pop($this->stack[$id]); } else { // Exit all scopes until token depth - while (!empty($this->stack[$id]) && count($this->stack[$id]) >= $token) { + while (! empty($this->stack[$id]) && count($this->stack[$id]) >= $token) { array_pop($this->stack[$id]); } } @@ -81,6 +81,7 @@ final class ErrorScope $stack = $this->stack[$id] ?? []; $current = end($stack); + return $current !== false ? $current : null; } @@ -90,7 +91,8 @@ final class ErrorScope public function hasScope(): bool { $id = $this->fiberId(); - return !empty($this->stack[$id]); + + return ! empty($this->stack[$id]); } /** @@ -99,6 +101,7 @@ final class ErrorScope public function depth(): int { $id = $this->fiberId(); + return count($this->stack[$id] ?? []); } @@ -110,6 +113,7 @@ final class ErrorScope private function fiberId(): int { $fiber = Fiber::getCurrent(); + return $fiber ? spl_object_id($fiber) : 0; } @@ -129,7 +133,7 @@ final class ErrorScope return [ 'active_fibers' => count($this->stack), 'total_scopes' => array_sum(array_map('count', $this->stack)), - 'max_depth' => !empty($this->stack) ? max(array_map('count', $this->stack)) : 0, + 'max_depth' => ! empty($this->stack) ? max(array_map('count', $this->stack)) : 0, ]; } } diff --git a/src/Framework/ExceptionHandling/Scope/ErrorScopeContext.php b/src/Framework/ExceptionHandling/Scope/ErrorScopeContext.php index 59aecab0..b4a2299d 100644 --- a/src/Framework/ExceptionHandling/Scope/ErrorScopeContext.php +++ b/src/Framework/ExceptionHandling/Scope/ErrorScopeContext.php @@ -41,7 +41,8 @@ final readonly class ErrorScopeContext public ?string $jobId = null, public ?string $commandName = null, public array $tags = [], - ) {} + ) { + } /** * Create HTTP scope from request diff --git a/src/Framework/ExceptionHandling/Serialization/ExceptionSerializer.php b/src/Framework/ExceptionHandling/Serialization/ExceptionSerializer.php index b6f78220..e141c7f0 100644 --- a/src/Framework/ExceptionHandling/Serialization/ExceptionSerializer.php +++ b/src/Framework/ExceptionHandling/Serialization/ExceptionSerializer.php @@ -6,7 +6,6 @@ namespace App\Framework\ExceptionHandling\Serialization; use App\Framework\ExceptionHandling\Context\ExceptionContextData; use App\Framework\ExceptionHandling\Context\ExceptionContextProvider; -use App\Framework\Logging\ValueObjects\ExceptionContext as LoggingExceptionContext; use Throwable; /** @@ -36,6 +35,7 @@ final readonly class ExceptionSerializer public function toJson(Throwable $exception, array $options = []): string { $data = $this->toArray($exception, $options); + return json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } @@ -109,12 +109,12 @@ final readonly class ExceptionSerializer ]); // Add domain data - if (!empty($context->data)) { + if (! empty($context->data)) { $logData['domain_data'] = $context->data; } // Add metadata - if (!empty($context->metadata)) { + if (! empty($context->metadata)) { $logData['metadata'] = $context->metadata; } } @@ -290,6 +290,7 @@ final readonly class ExceptionSerializer $serialized[] = $arg; } } + return $serialized; } @@ -332,4 +333,3 @@ final readonly class ExceptionSerializer return (string) $value; } } - diff --git a/src/Framework/ExceptionHandling/ShutdownHandler.php b/src/Framework/ExceptionHandling/ShutdownHandler.php index d8a67aea..86c71846 100644 --- a/src/Framework/ExceptionHandling/ShutdownHandler.php +++ b/src/Framework/ExceptionHandling/ShutdownHandler.php @@ -1,4 +1,5 @@ isProduction() => new StrictErrorPolicy(), - $environmentType->isDevelopment() => $this->logger !== null + $environmentType->isDevelopment() => $this->logger !== null ? new LenientPolicy($this->logger) : new StrictErrorPolicy(), default => new SilentErrorPolicy(), diff --git a/src/Framework/ExceptionHandling/Strategy/LenientPolicy.php b/src/Framework/ExceptionHandling/Strategy/LenientPolicy.php index 544b9f3c..9fb0ea9c 100644 --- a/src/Framework/ExceptionHandling/Strategy/LenientPolicy.php +++ b/src/Framework/ExceptionHandling/Strategy/LenientPolicy.php @@ -1,4 +1,5 @@ isDeprecation()) { - $this->logger->notice("[Deprecation] {$context->message}", + if ($context->isDeprecation()) { + $this->logger->notice( + "[Deprecation] {$context->message}", LogContext::withData( [ 'file' => $context->file, - 'line' => $context->line?->toInt() + 'line' => $context->line?->toInt(), ] - )); + ) + ); return ErrorDecision::HANDLED; } - if($context->isFatal()) { + if ($context->isFatal()) { throw new ErrorException( $context->message, 0, diff --git a/src/Framework/ExceptionHandling/Strategy/SilentErrorPolicy.php b/src/Framework/ExceptionHandling/Strategy/SilentErrorPolicy.php index b559609a..fdc2f07e 100644 --- a/src/Framework/ExceptionHandling/Strategy/SilentErrorPolicy.php +++ b/src/Framework/ExceptionHandling/Strategy/SilentErrorPolicy.php @@ -1,4 +1,5 @@ + + + + + {{ $title }} + + + +
+
+

{{ $title }}

+ {{ $isDebugMode ? 'DEBUG' : 'PROD' }} +
+ +
+ Template fallback: {{ $templateError }} +
+ +
+

{{ $message }}

+
+ +
+
+ Status + {{ $statusCode }} +
+
+ Exception + {{ $exceptionClass }} +
+
+ Request + {{ $requestMethod }} {{ $requestUri }} +
+
+ +
+

Debug Information

+ +
+ Exception: + {{ $exceptionClass }} +
+
+ File: + {{ $debug['file'] ?? '' }}:{{ $debug['line'] ?? '' }} +
+
+ Short trace: + {{ $debug['trace_short'] ?? '' }} +
+ +
+
+ Operation: + {{ $context['operation'] }} +
+
+ Component: + {{ $context['component'] }} +
+
+ Request ID: + {{ $context['request_id'] }} +
+
+ Occurred At: + {{ $context['occurred_at'] }} +
+
+ +
+ {!! $codeSnippet !!} +
+ +
+
+

Stack Trace

+ +
+
+ {!! $stackTraceHtml !!} +
+
{{ $debug['trace'] ?? '' }}
+
{{ $debug['trace_plain'] ?? '' }}
+
+
+ + +
+ + + + diff --git a/src/Framework/ExceptionHandling/Translation/ExceptionMessageTranslator.php b/src/Framework/ExceptionHandling/Translation/ExceptionMessageTranslator.php index 07a82135..853a50a1 100644 --- a/src/Framework/ExceptionHandling/Translation/ExceptionMessageTranslator.php +++ b/src/Framework/ExceptionHandling/Translation/ExceptionMessageTranslator.php @@ -52,6 +52,7 @@ final readonly class ExceptionMessageTranslator // Process template if (is_string($template)) { $message = $this->processTemplate($template, $exception, $context); + return UserFriendlyMessage::simple($message); } @@ -97,4 +98,3 @@ final readonly class ExceptionMessageTranslator ); } } - diff --git a/src/Framework/ExceptionHandling/Translation/UserFriendlyMessage.php b/src/Framework/ExceptionHandling/Translation/UserFriendlyMessage.php index 1cf0f055..2bc101da 100644 --- a/src/Framework/ExceptionHandling/Translation/UserFriendlyMessage.php +++ b/src/Framework/ExceptionHandling/Translation/UserFriendlyMessage.php @@ -49,4 +49,3 @@ final readonly class UserFriendlyMessage return new self($message, helpText: $helpText); } } - diff --git a/src/Framework/ExceptionHandling/ValueObjects/StackItem.php b/src/Framework/ExceptionHandling/ValueObjects/StackItem.php index 495a4e4b..fe3a3eeb 100644 --- a/src/Framework/ExceptionHandling/ValueObjects/StackItem.php +++ b/src/Framework/ExceptionHandling/ValueObjects/StackItem.php @@ -61,7 +61,7 @@ final readonly class StackItem private static function sanitizeArgs(array $args): array { return array_map( - fn($arg) => self::sanitizeValue($arg), + fn ($arg) => self::sanitizeValue($arg), $args ); } @@ -77,6 +77,7 @@ final readonly class StackItem $reflection = new \ReflectionFunction($value); $file = $reflection->getFileName(); $line = $reflection->getStartLine(); + return sprintf('Closure(%s:%d)', basename($file), $line); } catch (\Throwable) { return 'Closure'; @@ -102,6 +103,7 @@ final readonly class StackItem if ($parentClass !== false) { return sprintf('Anonymous(%s)', $parentClass); } + return 'Anonymous'; } @@ -112,7 +114,7 @@ final readonly class StackItem // Arrays rekursiv bereinigen if (is_array($value)) { return array_map( - fn($item) => self::sanitizeValue($item), + fn ($item) => self::sanitizeValue($item), $value ); } @@ -145,15 +147,17 @@ final readonly class StackItem // Entferne Namespace: Spalte am Backslash und nimm den letzten Teil $parts = explode('\\', $interfaceName); $shortName = end($parts); + return $shortName . ' (anonymous)'; } + return 'Anonymous'; } // Spalte am Backslash und nimm den letzten Teil (Klassenname ohne Namespace) $parts = explode('\\', $normalizedClass); $shortName = end($parts); - + // Sicherstellen, dass wir wirklich nur den letzten Teil zurückgeben // (falls explode() nicht funktioniert hat, z.B. bei Forward-Slashes) if ($shortName === $normalizedClass && str_contains($normalizedClass, '/')) { @@ -161,7 +165,7 @@ final readonly class StackItem $parts = explode('/', $normalizedClass); $shortName = end($parts); } - + return $shortName; } @@ -226,6 +230,7 @@ final readonly class StackItem foreach ($this->args as $index => $arg) { if ($index >= $maxArgs) { $formatted[] = '…'; + break; } @@ -235,6 +240,7 @@ final readonly class StackItem if ($length > $maxTotalLength) { $formatted[] = '…'; + break; } } @@ -271,6 +277,7 @@ final readonly class StackItem if (class_exists($normalizedValue) || interface_exists($normalizedValue) || enum_exists($normalizedValue)) { $parts = explode('\\', $normalizedValue); $shortName = end($parts); + return sprintf("'%s'", $shortName); } @@ -282,6 +289,7 @@ final readonly class StackItem // Nur wenn es mehrere Teile gibt (Namespace vorhanden) if (count($parts) > 1) { $shortName = end($parts); + return sprintf("'%s'", $shortName); } } @@ -290,6 +298,7 @@ final readonly class StackItem if (preg_match('/^Closure\(([^:]+):(\d+)\)$/', $value, $matches)) { $file = basename($matches[1]); $line = $matches[2]; + return sprintf("Closure(%s:%s)", $file, $line); } @@ -319,8 +328,10 @@ final readonly class StackItem $interfaceName = $matches[1]; $interfaceParts = explode('\\', $interfaceName); $shortInterface = end($interfaceParts); + return $shortInterface . ' (anonymous)'; } + return 'Anonymous'; } @@ -341,25 +352,26 @@ final readonly class StackItem $closureInfo = $matches[1]; // Normalisiere Forward-Slashes zu Backslashes $closureInfo = str_replace('/', '\\', $closureInfo); - + // Parse: App\Framework\Router\RouteDispatcher::executeController():77 if (preg_match('/^([^:]+)::([^(]+)\(\):(\d+)$/', $closureInfo, $closureMatches)) { $fullClass = $closureMatches[1]; $method = $closureMatches[2]; $line = $closureMatches[3]; - + // Entferne Namespace $classParts = explode('\\', $fullClass); $shortClass = end($classParts); - + return sprintf('{closure:%s::%s():%s}', $shortClass, $method, $line); } // Fallback: einfach Namespaces entfernen $closureInfo = preg_replace_callback( '/([A-Z][a-zA-Z0-9_\\\\]*)/', - fn($m) => $this->removeNamespaceFromClass($m[0]), + fn ($m) => $this->removeNamespaceFromClass($m[0]), $closureInfo ); + return sprintf('{closure:%s}', $closureInfo); } @@ -374,6 +386,7 @@ final readonly class StackItem // Normalisiere Forward-Slashes zu Backslashes $normalized = str_replace('/', '\\', $classString); $parts = explode('\\', $normalized); + return end($parts); } @@ -403,6 +416,7 @@ final readonly class StackItem $methodName = $this->formatFunctionName($this->function); $separator = $this->type === '::' ? '::' : '->'; $paramsStr = $params !== '' ? $params : ''; + return sprintf('%s%s%s(%s) in %s', $className, $separator, $methodName, $paramsStr, $location); } @@ -410,6 +424,7 @@ final readonly class StackItem if ($this->function !== null) { $methodName = $this->formatFunctionName($this->function); $paramsStr = $params !== '' ? $params : ''; + return sprintf('%s(%s) in %s', $methodName, $paramsStr, $location); } @@ -443,7 +458,7 @@ final readonly class StackItem $data['type'] = $this->type; } - if (!empty($this->args)) { + if (! empty($this->args)) { $data['args'] = $this->serializeArgs(); } @@ -458,7 +473,7 @@ final readonly class StackItem private function serializeArgs(): array { return array_map( - fn($arg) => $this->formatValueForOutput($arg), + fn ($arg) => $this->formatValueForOutput($arg), $this->args ); } @@ -484,6 +499,7 @@ final readonly class StackItem } $projectRoot = dirname(__DIR__, 4); + return $projectRoot; } } diff --git a/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php b/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php index b7227225..3e4bcdf7 100644 --- a/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php +++ b/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Framework\ExceptionHandling\ValueObjects; -use IteratorAggregate; use Countable; +use IteratorAggregate; use Throwable; /** @@ -48,7 +48,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable array_values( array_filter( $this->items, - fn(StackItem $item) => !$item->isVendorFrame() + fn (StackItem $item) => ! $item->isVendorFrame() ) ) ); @@ -104,7 +104,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable { $frames = array_slice($this->items, 0, $maxFrames); $formatted = array_map( - fn(StackItem $item) => $item->formatShort(), + fn (StackItem $item) => $item->formatShort(), $frames ); @@ -123,7 +123,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable public function toArray(): array { return array_map( - fn(StackItem $item) => $item->toArray(), + fn (StackItem $item) => $item->toArray(), $this->items ); } @@ -148,4 +148,3 @@ final readonly class StackTrace implements IteratorAggregate, Countable return count($this->items); } } - diff --git a/tests/Unit/Framework/Core/ValueObjects/FrameworkModuleRegistryTest.php b/tests/Unit/Framework/Core/ValueObjects/FrameworkModuleRegistryTest.php new file mode 100644 index 00000000..196630e1 --- /dev/null +++ b/tests/Unit/Framework/Core/ValueObjects/FrameworkModuleRegistryTest.php @@ -0,0 +1,237 @@ +count())->toBe(3); + expect($registry->hasModule('Http'))->toBeTrue(); + expect($registry->hasModule('Database'))->toBeTrue(); + expect($registry->hasModule('Cache'))->toBeTrue(); + }); + + it('creates empty registry', function () { + $registry = new FrameworkModuleRegistry(); + + expect($registry->count())->toBe(0); + expect($registry->getAllModules())->toBe([]); + }); + + describe('discover', function () { + it('discovers modules from Framework directory', function () { + // Use actual project path (tests run outside Docker) + $projectRoot = dirname(__DIR__, 5); + $frameworkPath = FilePath::create($projectRoot . '/src/Framework'); + + $registry = FrameworkModuleRegistry::discover($frameworkPath); + + expect($registry->count())->toBeGreaterThan(50); + expect($registry->hasModule('Http'))->toBeTrue(); + expect($registry->hasModule('Database'))->toBeTrue(); + expect($registry->hasModule('Core'))->toBeTrue(); + expect($registry->hasModule('Cache'))->toBeTrue(); + }); + }); + + describe('getModuleForNamespace', function () { + beforeEach(function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $this->registry = new FrameworkModuleRegistry( + FrameworkModule::create('Http', $basePath), + FrameworkModule::create('Database', $basePath), + FrameworkModule::create('Cache', $basePath) + ); + }); + + it('finds module for namespace', function () { + $namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth'); + + $module = $this->registry->getModuleForNamespace($namespace); + + expect($module)->not->toBeNull(); + expect($module->name)->toBe('Http'); + }); + + it('finds module for root module namespace', function () { + $namespace = PhpNamespace::fromString('App\\Framework\\Database'); + + $module = $this->registry->getModuleForNamespace($namespace); + + expect($module)->not->toBeNull(); + expect($module->name)->toBe('Database'); + }); + + it('returns null for non-framework namespace', function () { + $namespace = PhpNamespace::fromString('App\\Domain\\User\\Services'); + + $module = $this->registry->getModuleForNamespace($namespace); + + expect($module)->toBeNull(); + }); + + it('returns null for unknown module', function () { + $namespace = PhpNamespace::fromString('App\\Framework\\UnknownModule\\Something'); + + $module = $this->registry->getModuleForNamespace($namespace); + + expect($module)->toBeNull(); + }); + + it('returns null for bare Framework namespace', function () { + $namespace = PhpNamespace::fromString('App\\Framework'); + + $module = $this->registry->getModuleForNamespace($namespace); + + expect($module)->toBeNull(); + }); + }); + + describe('getModuleForClass', function () { + beforeEach(function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $this->registry = new FrameworkModuleRegistry( + FrameworkModule::create('Http', $basePath), + FrameworkModule::create('Core', $basePath) + ); + }); + + it('finds module for class', function () { + $className = ClassName::create('App\\Framework\\Http\\Request'); + + $module = $this->registry->getModuleForClass($className); + + expect($module)->not->toBeNull(); + expect($module->name)->toBe('Http'); + }); + + it('finds module for deeply nested class', function () { + $className = ClassName::create('App\\Framework\\Core\\ValueObjects\\PhpNamespace'); + + $module = $this->registry->getModuleForClass($className); + + expect($module)->not->toBeNull(); + expect($module->name)->toBe('Core'); + }); + }); + + describe('inSameModule', function () { + beforeEach(function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $this->registry = new FrameworkModuleRegistry( + FrameworkModule::create('Http', $basePath), + FrameworkModule::create('Database', $basePath) + ); + }); + + it('returns true for namespaces in same module', function () { + $a = PhpNamespace::fromString('App\\Framework\\Http\\Request'); + $b = PhpNamespace::fromString('App\\Framework\\Http\\Response'); + + expect($this->registry->inSameModule($a, $b))->toBeTrue(); + }); + + it('returns true for deeply nested namespaces in same module', function () { + $a = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth'); + $b = PhpNamespace::fromString('App\\Framework\\Http\\ValueObjects\\StatusCode'); + + expect($this->registry->inSameModule($a, $b))->toBeTrue(); + }); + + it('returns false for namespaces in different modules', function () { + $a = PhpNamespace::fromString('App\\Framework\\Http\\Request'); + $b = PhpNamespace::fromString('App\\Framework\\Database\\Connection'); + + expect($this->registry->inSameModule($a, $b))->toBeFalse(); + }); + + it('returns false when one namespace is not in framework', function () { + $a = PhpNamespace::fromString('App\\Framework\\Http\\Request'); + $b = PhpNamespace::fromString('App\\Domain\\User\\UserService'); + + expect($this->registry->inSameModule($a, $b))->toBeFalse(); + }); + + it('returns false when both namespaces are not in framework', function () { + $a = PhpNamespace::fromString('App\\Domain\\User'); + $b = PhpNamespace::fromString('App\\Domain\\Order'); + + expect($this->registry->inSameModule($a, $b))->toBeFalse(); + }); + }); + + describe('classesInSameModule', function () { + beforeEach(function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $this->registry = new FrameworkModuleRegistry( + FrameworkModule::create('Http', $basePath), + FrameworkModule::create('Cache', $basePath) + ); + }); + + it('returns true for classes in same module', function () { + $a = ClassName::create('App\\Framework\\Http\\Request'); + $b = ClassName::create('App\\Framework\\Http\\Response'); + + expect($this->registry->classesInSameModule($a, $b))->toBeTrue(); + }); + + it('returns false for classes in different modules', function () { + $a = ClassName::create('App\\Framework\\Http\\Request'); + $b = ClassName::create('App\\Framework\\Cache\\CacheItem'); + + expect($this->registry->classesInSameModule($a, $b))->toBeFalse(); + }); + }); + + describe('getModule', function () { + it('returns module by name', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $registry = new FrameworkModuleRegistry( + FrameworkModule::create('Http', $basePath) + ); + + $module = $registry->getModule('Http'); + + expect($module)->not->toBeNull(); + expect($module->name)->toBe('Http'); + }); + + it('returns null for unknown module', function () { + $registry = new FrameworkModuleRegistry(); + + expect($registry->getModule('Unknown'))->toBeNull(); + }); + }); + + describe('getModuleNames', function () { + it('returns all module names', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $registry = new FrameworkModuleRegistry( + FrameworkModule::create('Http', $basePath), + FrameworkModule::create('Database', $basePath), + FrameworkModule::create('Cache', $basePath) + ); + + $names = $registry->getModuleNames(); + + expect($names)->toContain('Http'); + expect($names)->toContain('Database'); + expect($names)->toContain('Cache'); + expect($names)->toHaveCount(3); + }); + }); +}); diff --git a/tests/Unit/Framework/Core/ValueObjects/FrameworkModuleTest.php b/tests/Unit/Framework/Core/ValueObjects/FrameworkModuleTest.php new file mode 100644 index 00000000..8fa18ca0 --- /dev/null +++ b/tests/Unit/Framework/Core/ValueObjects/FrameworkModuleTest.php @@ -0,0 +1,160 @@ +name)->toBe('Http'); + expect($module->path->toString())->toBe('/var/www/html/src/Framework/Http'); + expect($module->namespace->toString())->toBe('App\\Framework\\Http'); + }); + + it('creates module via factory method', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Database', $basePath); + + expect($module->name)->toBe('Database'); + expect($module->path->toString())->toBe('/var/www/html/src/Framework/Database'); + }); + + it('rejects empty module name', function () { + $path = FilePath::create('/var/www/html/src/Framework/Empty'); + new FrameworkModule('', $path); + })->throws(InvalidArgumentException::class, 'Module name cannot be empty'); + + it('rejects lowercase module name', function () { + $path = FilePath::create('/var/www/html/src/Framework/http'); + new FrameworkModule('http', $path); + })->throws(InvalidArgumentException::class, 'Must be PascalCase'); + + it('rejects module name with invalid characters', function () { + $path = FilePath::create('/var/www/html/src/Framework/Http-Client'); + new FrameworkModule('Http-Client', $path); + })->throws(InvalidArgumentException::class, 'Must be PascalCase'); + + describe('containsNamespace', function () { + it('returns true for namespace within module', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares'); + + expect($module->containsNamespace($namespace))->toBeTrue(); + }); + + it('returns true for module root namespace', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $namespace = PhpNamespace::fromString('App\\Framework\\Http'); + + expect($module->containsNamespace($namespace))->toBeTrue(); + }); + + it('returns false for namespace in different module', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $namespace = PhpNamespace::fromString('App\\Framework\\Database\\Query'); + + expect($module->containsNamespace($namespace))->toBeFalse(); + }); + + it('returns false for non-framework namespace', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $namespace = PhpNamespace::fromString('App\\Domain\\User'); + + expect($module->containsNamespace($namespace))->toBeFalse(); + }); + }); + + describe('containsClass', function () { + it('returns true for class within module', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $className = ClassName::create('App\\Framework\\Http\\Request'); + + expect($module->containsClass($className))->toBeTrue(); + }); + + it('returns false for class in different module', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $className = ClassName::create('App\\Framework\\Cache\\CacheItem'); + + expect($module->containsClass($className))->toBeFalse(); + }); + }); + + describe('getRelativeNamespace', function () { + it('returns relative namespace within module', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth'); + $relative = $module->getRelativeNamespace($namespace); + + expect($relative)->not->toBeNull(); + expect($relative->toString())->toBe('Middlewares\\Auth'); + }); + + it('returns global namespace for module root', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $namespace = PhpNamespace::fromString('App\\Framework\\Http'); + $relative = $module->getRelativeNamespace($namespace); + + expect($relative)->not->toBeNull(); + expect($relative->isGlobal())->toBeTrue(); + }); + + it('returns null for namespace not in module', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + $namespace = PhpNamespace::fromString('App\\Framework\\Database'); + $relative = $module->getRelativeNamespace($namespace); + + expect($relative)->toBeNull(); + }); + }); + + describe('equals', function () { + it('returns true for modules with same name', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module1 = FrameworkModule::create('Http', $basePath); + $module2 = FrameworkModule::create('Http', $basePath); + + expect($module1->equals($module2))->toBeTrue(); + }); + + it('returns false for modules with different names', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module1 = FrameworkModule::create('Http', $basePath); + $module2 = FrameworkModule::create('Cache', $basePath); + + expect($module1->equals($module2))->toBeFalse(); + }); + }); + + it('converts to string', function () { + $basePath = FilePath::create('/var/www/html/src/Framework'); + $module = FrameworkModule::create('Http', $basePath); + + expect((string) $module)->toBe('Http'); + expect($module->toString())->toBe('Http'); + }); +}); diff --git a/tests/integration/ExceptionHandlingIntegrationTest.php b/tests/integration/ExceptionHandlingIntegrationTest.php index ac679081..610aded7 100644 --- a/tests/integration/ExceptionHandlingIntegrationTest.php +++ b/tests/integration/ExceptionHandlingIntegrationTest.php @@ -4,13 +4,187 @@ declare(strict_types=1); namespace Tests\Integration; +use App\Framework\Cache\Cache; +use App\Framework\Cache\CacheIdentifier; +use App\Framework\Cache\CacheItem; +use App\Framework\Cache\CacheKey; +use App\Framework\Cache\CacheResult; +use App\Framework\Cache\Driver\InMemoryCache; +use App\Framework\Core\PathProvider; +use App\Framework\Core\ValueObjects\Duration; +use App\Framework\DI\DefaultContainer; use App\Framework\ExceptionHandling\Context\ExceptionContextData; use App\Framework\ExceptionHandling\Context\ExceptionContextProvider; +use App\Framework\ExceptionHandling\ErrorHandlingConfig; use App\Framework\ExceptionHandling\ErrorKernel; +use App\Framework\ExceptionHandling\ErrorRendererFactory; use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer; use App\Framework\Http\Status; +use App\Framework\Performance\PerformanceCategory; +use App\Framework\Performance\Contracts\PerformanceCollectorInterface; +use App\Framework\Performance\PerformanceConfig; +use App\Framework\Performance\PerformanceMetric; +use App\Framework\Performance\PerformanceService; +use App\Framework\Serialization\Serializer; +use App\Framework\View\Engine; +use App\Framework\Context\ExecutionContext; +use App\Framework\View\Loading\TemplateLoader; +use App\Framework\View\TemplateProcessor; +use Mockery; use RuntimeException; +/** + * Null performance collector for testing (no-op implementation) + */ +class TestPerformanceCollector implements PerformanceCollectorInterface +{ + public function startTiming(string $key, PerformanceCategory $category, array $context = []): void {} + public function endTiming(string $key): void {} + public function measure(string $key, PerformanceCategory $category, callable $callback, array $context = []): mixed + { + return $callback(); + } + public function recordMetric(string $key, PerformanceCategory $category, float $value, array $context = []): void {} + public function increment(string $key, PerformanceCategory $category, int $amount = 1, array $context = []): void {} + public function getMetrics(?PerformanceCategory $category = null): array { return []; } + public function getMetric(string $key): ?PerformanceMetric { return null; } + public function getTotalRequestTime(): float { return 0.0; } + public function getTotalRequestMemory(): int { return 0; } + public function getPeakMemory(): int { return 0; } + public function reset(): void {} + public function isEnabled(): bool { return false; } + public function setEnabled(bool $enabled): void {} +} + +/** + * Simple cache wrapper for testing - adapts InMemoryCache to Cache interface + */ +class SimpleCacheWrapper implements Cache +{ + public function __construct(private InMemoryCache $driver) {} + + public function get(CacheIdentifier ...$identifiers): CacheResult + { + $keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey); + return $this->driver->get(...$keys); + } + + public function set(CacheItem ...$items): bool + { + return $this->driver->set(...$items); + } + + public function has(CacheIdentifier ...$identifiers): array + { + $keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey); + return $this->driver->has(...$keys); + } + + public function forget(CacheIdentifier ...$identifiers): bool + { + $keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey); + return $this->driver->forget(...$keys); + } + + public function clear(): bool + { + return $this->driver->clear(); + } + + public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem + { + $result = $this->driver->get($key); + $item = $result->getItem($key); + if ($item->isHit) { + return $item; + } + $value = $callback(); + $newItem = CacheItem::forSet($key, $value, $ttl); + $this->driver->set($newItem); + return CacheItem::hit($key, $value); + } +} + +/** + * Helper functions to create test dependencies + * Following the dependency chain: TemplateLoader → Engine → ErrorRendererFactory → ErrorKernel + */ + +function createTestEngine(): Engine +{ + $projectRoot = dirname(__DIR__, 2); + $pathProvider = new PathProvider($projectRoot); + $cache = new SimpleCacheWrapper(new InMemoryCache()); + + $templateLoader = new TemplateLoader( + pathProvider: $pathProvider, + cache: $cache, + discoveryRegistry: null, + templates: [], + templatePath: '/src/Framework/ExceptionHandling/Templates', + useMtimeInvalidation: false, + cacheEnabled: false, + ); + + $performanceCollector = new TestPerformanceCollector(); + $performanceConfig = new PerformanceConfig(enabled: false); + $performanceService = new PerformanceService( + collector: $performanceCollector, + config: $performanceConfig, + ); + + $container = new DefaultContainer(); + $templateProcessor = new TemplateProcessor( + astTransformers: [], + stringProcessors: [], + container: $container, + ); + + return new Engine( + loader: $templateLoader, + performanceService: $performanceService, + processor: $templateProcessor, + cache: $cache, + cacheEnabled: false, + ); +} + +function createTestErrorRendererFactory(?bool $isDebugMode = null): ErrorRendererFactory +{ + $executionContext = ExecutionContext::forWeb(); + $engine = createTestEngine(); + $config = $isDebugMode !== null + ? new ErrorHandlingConfig(isDebugMode: $isDebugMode) + : null; + + return new ErrorRendererFactory( + executionContext: $executionContext, + engine: $engine, + consoleOutput: null, + config: $config, + ); +} + +function createTestErrorKernel(): ErrorKernel +{ + $rendererFactory = createTestErrorRendererFactory(); + + return new ErrorKernel( + rendererFactory: $rendererFactory, + reporter: null, + ); +} + +function createTestResponseErrorRenderer(bool $isDebugMode = false): ResponseErrorRenderer +{ + $engine = createTestEngine(); + + return new ResponseErrorRenderer( + engine: $engine, + isDebugMode: $isDebugMode, + ); +} + /** * Integration tests for unified ExceptionHandling module * @@ -32,7 +206,7 @@ describe('ErrorKernel HTTP Response Generation', function () { }); it('creates JSON API error response without context', function () { - $errorKernel = new ErrorKernel(); + $errorKernel = createTestErrorKernel(); $exception = new RuntimeException('Test API error', 500); $response = $errorKernel->createHttpResponse($exception, null, isDebugMode: false); @@ -47,7 +221,7 @@ describe('ErrorKernel HTTP Response Generation', function () { }); it('creates JSON API error response with debug mode', function () { - $errorKernel = new ErrorKernel(); + $errorKernel = createTestErrorKernel(); $exception = new RuntimeException('Database connection failed', 500); $response = $errorKernel->createHttpResponse($exception, null, isDebugMode: true); @@ -63,7 +237,7 @@ describe('ErrorKernel HTTP Response Generation', function () { }); it('creates JSON API error response with WeakMap context', function () { - $errorKernel = new ErrorKernel(); + $errorKernel = createTestErrorKernel(); $contextProvider = new ExceptionContextProvider(); $exception = new RuntimeException('User operation failed', 500); @@ -100,10 +274,10 @@ describe('ResponseErrorRenderer', function () { }); it('detects API requests correctly', function () { - $renderer = new ResponseErrorRenderer(isDebugMode: false); + $renderer = createTestResponseErrorRenderer(isDebugMode: false); $exception = new RuntimeException('Test error'); - $response = $renderer->createResponse($exception, null); + $response = $renderer->render($exception, null); expect($response->headers->getFirst('Content-Type'))->toBe('application/json'); }); @@ -113,10 +287,10 @@ describe('ResponseErrorRenderer', function () { $_SERVER['HTTP_ACCEPT'] = 'text/html'; $_SERVER['REQUEST_URI'] = '/web/page'; - $renderer = new ResponseErrorRenderer(isDebugMode: false); + $renderer = createTestResponseErrorRenderer(isDebugMode: false); $exception = new RuntimeException('Page error'); - $response = $renderer->createResponse($exception, null); + $response = $renderer->render($exception, null); expect($response->headers->getFirst('Content-Type'))->toBe('text/html; charset=utf-8'); expect($response->body)->toContain(''); @@ -127,7 +301,7 @@ describe('ResponseErrorRenderer', function () { $_SERVER['HTTP_ACCEPT'] = 'text/html'; $_SERVER['REQUEST_URI'] = '/web/page'; - $renderer = new ResponseErrorRenderer(isDebugMode: true); + $renderer = createTestResponseErrorRenderer(isDebugMode: true); $contextProvider = new ExceptionContextProvider(); $exception = new RuntimeException('Debug test error'); @@ -139,7 +313,7 @@ describe('ResponseErrorRenderer', function () { ); $contextProvider->attach($exception, $contextData); - $response = $renderer->createResponse($exception, $contextProvider); + $response = $renderer->render($exception, $contextProvider); expect($response->body)->toContain('Debug Information'); expect($response->body)->toContain('page.render'); @@ -148,21 +322,21 @@ describe('ResponseErrorRenderer', function () { }); it('maps exception types to HTTP status codes correctly', function () { - $renderer = new ResponseErrorRenderer(); + $renderer = createTestResponseErrorRenderer(); // InvalidArgumentException → 400 $exception = new \InvalidArgumentException('Invalid input'); - $response = $renderer->createResponse($exception, null); + $response = $renderer->render($exception, null); expect($response->status)->toBe(Status::BAD_REQUEST); // RuntimeException → 500 $exception = new RuntimeException('Runtime error'); - $response = $renderer->createResponse($exception, null); + $response = $renderer->render($exception, null); expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR); // Custom code in valid range $exception = new RuntimeException('Not found', 404); - $response = $renderer->createResponse($exception, null); + $response = $renderer->render($exception, null); expect($response->status)->toBe(Status::NOT_FOUND); }); }); @@ -297,7 +471,7 @@ describe('End-to-end integration scenario', function () { it('demonstrates full exception handling flow with context enrichment', function () { // Setup - $errorKernel = new ErrorKernel(); + $errorKernel = createTestErrorKernel(); $contextProvider = new ExceptionContextProvider(); // 1. Exception occurs in service layer