fix(Console): add void as valid return type for command methods
All checks were successful
Test Runner / test-php (push) Successful in 31s
Deploy Application / deploy (push) Successful in 1m42s
Test Runner / test-basic (push) Successful in 7s

The MethodSignatureAnalyzer was rejecting command methods with void return
type, causing the schedule:run command to fail validation.
This commit is contained in:
2025-11-26 06:16:09 +01:00
parent 386baff65f
commit c93d3f07a2
73 changed files with 1674 additions and 163 deletions

View File

@@ -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"}}
{"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"}}

View File

@@ -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

View File

@@ -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,
];
}

View File

@@ -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') ?? '',
];
}

View File

@@ -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',

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Filesystem\ValueObjects\FilePath;
use InvalidArgumentException;
use Stringable;
/**
* Immutable value object representing a Framework module
*
* A module is a top-level directory within src/Framework/ that groups
* related functionality (e.g., Http, Database, Cache, Queue).
*/
final readonly class FrameworkModule implements Stringable
{
private const string FRAMEWORK_NAMESPACE_PREFIX = 'App\\Framework\\';
public PhpNamespace $namespace;
public function __construct(
public string $name,
public FilePath $path
) {
if (empty($name)) {
throw new InvalidArgumentException('Module name cannot be empty');
}
if (! preg_match('/^[A-Z][a-zA-Z0-9]*$/', $name)) {
throw new InvalidArgumentException(
"Invalid module name: {$name}. Must be PascalCase starting with uppercase letter."
);
}
$this->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;
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Filesystem\ValueObjects\FilePath;
use InvalidArgumentException;
/**
* Registry of all Framework modules with lookup capabilities
*
* Provides efficient lookup of which module a namespace, class, or file belongs to.
* Modules are discovered lazily from the Framework directory structure.
*/
final readonly class FrameworkModuleRegistry
{
private const string FRAMEWORK_NAMESPACE_PREFIX = 'App\\Framework\\';
/** @var array<string, FrameworkModule> 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<string, FrameworkModule>
*/
public function getAllModules(): array
{
return $this->modules;
}
/**
* Get all module names
*
* @return array<string>
*/
public function getModuleNames(): array
{
return array_keys($this->modules);
}
/**
* Get module count
*/
public function count(): int
{
return count($this->modules);
}
}

View File

@@ -20,4 +20,3 @@ final readonly class DiscoveryWarning
) {
}
}

View File

@@ -101,4 +101,3 @@ final class DiscoveryWarningAggregator
return $this->warningsByFile !== [];
}
}

View File

@@ -25,4 +25,3 @@ final readonly class DiscoveryWarningGroup
return count($this->warnings);
}
}

View File

@@ -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);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -32,4 +33,3 @@ final readonly class BasicErrorHandler implements ErrorHandlerInterface
return false;
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -31,4 +32,3 @@ final readonly class BasicGlobalExceptionHandler implements ExceptionHandler
file_put_contents('php://stderr', $errorOutput);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -16,7 +17,7 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
{
$last = error_get_last();
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
return;
}
@@ -49,6 +50,4 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
// ignore
}
}
}

View File

@@ -73,6 +73,7 @@ final readonly class ExceptionContextBuilder
if ($baseContext !== null) {
return $this->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;
}
}

View File

@@ -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);
}
}

View File

@@ -37,4 +37,3 @@ final readonly class ExceptionCorrelation
];
}
}

View File

@@ -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);
}
}

View File

@@ -1,10 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Core\ValueObjects\LineNumber;
use ErrorException;
final readonly class ErrorContext
{
@@ -14,7 +14,8 @@ final readonly class ErrorContext
public ?string $file = null,
public ?LineNumber $line = null,
public bool $isSuppressed = false,
) {}
) {
}
public static function create(
int $severity,

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
enum ErrorDecision

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -9,9 +10,9 @@ use ErrorException;
final readonly class ErrorHandler implements ErrorHandlerInterface
{
public function __construct(
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy,
) {}
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy(),
) {
}
/**
* @throws ErrorException
@@ -42,6 +43,6 @@ final readonly class ErrorHandler implements ErrorHandlerInterface
private function isSuppressed($severity): bool
{
return !(error_reporting() & $severity);
return ! (error_reporting() & $severity);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
interface ErrorHandlerInterface

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling;
declare(strict_types=1);
use ErrorException;
namespace App\Framework\ExceptionHandling;
interface ErrorHandlerStrategy
{

View File

@@ -19,7 +19,8 @@ final readonly class ErrorHandlingConfig
public bool $debugMode = false,
public bool $logErrors = true,
public bool $displayErrors = false
) {}
) {
}
/**
* Create config from EnvironmentType

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -10,9 +11,8 @@ use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
use App\Framework\ExceptionHandling\Reporter\Reporter;
use App\Framework\ExceptionHandling\Renderers\ConsoleErrorRenderer;
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
use App\Framework\ExceptionHandling\Reporter\Reporter;
use App\Framework\Http\HttpResponse;
use Throwable;
@@ -61,12 +61,12 @@ final readonly class ErrorKernel
$shouldSkipAudit = $this->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
};
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -35,6 +36,7 @@ final readonly class ErrorRendererFactory
if ($this->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);
}

View File

@@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
final class ErrorScopeContext
{
}

View File

@@ -85,4 +85,3 @@ enum ErrorSeverityType: int
};
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use Throwable;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;

View File

@@ -17,7 +17,8 @@ final readonly class ExceptionHandlerManagerFactory
{
public function __construct(
private Container $container
) {}
) {
}
/**
* Create and register ExceptionHandlerManager for current execution context
@@ -62,4 +63,3 @@ final readonly class ExceptionHandlerManagerFactory
);
}
}

View File

@@ -7,15 +7,15 @@ namespace App\Framework\ExceptionHandling;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Context\ExecutionContext;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
use App\Framework\ExceptionHandling\Reporter\LogReporter;
use App\Framework\ExceptionHandling\Reporter\Reporter;
use App\Framework\ExceptionHandling\Reporter\ReporterRegistry;
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
use App\Framework\Logging\Logger;
use App\Framework\View\Engine;
@@ -99,7 +99,7 @@ final readonly class ExceptionHandlingInitializer
$container->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);
}
}

View File

@@ -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);
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -16,7 +17,7 @@ final class FatalErrorTypes
E_PARSE,
E_CORE_ERROR,
E_COMPILE_ERROR,
E_USER_ERROR
E_USER_ERROR,
];
/**
@@ -27,4 +28,3 @@ final class FatalErrorTypes
return in_array($type, self::FATAL_TYPES, true);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -7,7 +8,8 @@ final readonly class GlobalExceptionHandler implements ExceptionHandler
{
public function __construct(
private ErrorKernel $errorKernel
) {}
) {
}
public function handle(\Throwable $throwable): void
{

View File

@@ -17,6 +17,7 @@ use App\Framework\Health\HealthCheckResult;
final readonly class ExceptionHealthChecker implements HealthCheckInterface
{
public readonly string $name;
public readonly int $timeout;
/**
@@ -105,4 +106,3 @@ final readonly class ExceptionHealthChecker implements HealthCheckInterface
return \App\Framework\Health\HealthCheckCategory::APPLICATION;
}
}

View File

@@ -81,4 +81,3 @@ final readonly class ExceptionLocalizer
return array_unique($chain);
}
}

View File

@@ -40,4 +40,3 @@ final readonly class ExceptionMetrics
];
}
}

View File

@@ -100,6 +100,7 @@ final readonly class ExceptionMetricsCollector
private function getMetric(string $metricName): int
{
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $metricName);
return $this->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);
}
}

View File

@@ -63,7 +63,7 @@ final readonly class PrometheusExporter
{
// Replace invalid characters
$sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
return $sanitized;
}
}

View File

@@ -180,4 +180,3 @@ final readonly class ErrorScopeMiddleware implements HttpMiddleware
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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;
}
}

View File

@@ -40,4 +40,3 @@ final readonly class FixSuggestion
];
}
}

View File

@@ -51,4 +51,3 @@ final readonly class ExceptionPerformanceMetrics
];
}
}

View File

@@ -108,4 +108,3 @@ final readonly class ExceptionPerformanceTracker
return null;
}
}

View File

@@ -143,4 +143,3 @@ final readonly class ExceptionFingerprint
return $this->hash;
}
}

View File

@@ -106,4 +106,3 @@ final readonly class ExceptionRateLimitConfig
);
}
}

View File

@@ -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);
}
}

View File

@@ -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
{
}

View File

@@ -31,4 +31,3 @@ enum RetryStrategy: string
};
}
}

View File

@@ -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;
}
}

View File

@@ -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
? '<div class="notice notice--warn">Template fallback: ' . htmlspecialchars($templateError, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</div>'
: '';
$debugBadge = $this->isDebugMode
? '<span class="badge badge--debug">DEBUG</span>'
: '<span class="badge badge--prod">PROD</span>';
$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
</head>
<body>
<div class="error-container">
<h1>{$title}</h1>
<h1>{$title} {$debugBadge}</h1>
{$templateNotice}
<div class="error-message">
<p>{$message}</p>
</div>
<div class="meta-grid">
<div class="meta-card">
<span class="meta-label">Status</span>
<strong>{$statusCode}</strong>
</div>
<div class="meta-card">
<span class="meta-label">Exception</span>
<strong>{$exceptionClass}</strong>
</div>
<div class="meta-card">
<span class="meta-label">Request</span>
<strong>{$requestMethod} {$requestUri}</strong>
</div>
</div>
{$debugInfo}
</div>
</body>
@@ -478,7 +568,7 @@ HTML;
<div class="stack-trace__list">
{$renderedFrames}
</div>
<pre id="full-trace-text" class="stack-trace__raw" aria-label="Stack trace">{$trace}</pre>
<pre id="full-trace-text" class="stack-trace__raw" aria-label="Stack trace" style="display:none">{$trace}</pre>
<pre id="full-trace-plain" style="display:none">{$tracePlain}</pre>
</div>
</div>
@@ -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(
'<details class="stack-frame%s"%s><summary><span class="stack-frame__index">#%d</span> <span class="stack-frame__call">%s</span> <span class="stack-frame__location">%s</span></summary>%s</details>',
'<details class="stack-frame%s"><summary><span class="stack-frame__index">#%d</span> <span class="stack-frame__call">%s</span> <span class="stack-frame__location">%s</span></summary>%s</details>',
$isVendor ? ' stack-frame--vendor' : '',
$expanded ? ' open' : '',
$index,
$callEscaped,
$locationEscaped,

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Reporter;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Reporter;
interface Reporter

View File

@@ -17,6 +17,7 @@ final readonly class ReporterRegistry implements Reporter
* @param Reporter[] $reporters Variadic list of reporter instances
*/
private array $reporters;
public function __construct(
Reporter ...$reporters
) {
@@ -46,4 +47,3 @@ final readonly class ReporterRegistry implements Reporter
}
}
}

View File

@@ -50,7 +50,7 @@ final class ErrorScope
{
$id = $this->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,
];
}
}

View File

@@ -41,7 +41,8 @@ final readonly class ErrorScopeContext
public ?string $jobId = null,
public ?string $commandName = null,
public array $tags = [],
) {}
) {
}
/**
* Create HTTP scope from request

View File

@@ -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;
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -9,13 +10,14 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
{
public function __construct(
private ErrorKernel $errorKernel
) {}
) {
}
public function handle(): void
{
$last = error_get_last();
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
return;
}
@@ -24,7 +26,7 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
$file = (string)($last['file'] ?? 'unknown');
$line = (int)($last['line'] ?? 0);
$error = new Error($last['message'] ?? 'Fatal error',0);
$error = new Error($last['message'] ?? 'Fatal error', 0);
@@ -49,5 +51,4 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
// ignore
}
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
interface ShutdownHandlerInterface

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
@@ -11,13 +12,14 @@ final readonly class ErrorPolicyResolver
{
public function __construct(
private ?Logger $logger = null
) {}
) {
}
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
{
return match(true) {
$environmentType->isProduction() => new StrictErrorPolicy(),
$environmentType->isDevelopment() => $this->logger !== null
$environmentType->isDevelopment() => $this->logger !== null
? new LenientPolicy($this->logger)
: new StrictErrorPolicy(),
default => new SilentErrorPolicy(),

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
@@ -19,19 +20,21 @@ final readonly class LenientPolicy implements ErrorHandlerStrategy
public function handle(ErrorContext $context): ErrorDecision
{
if($context->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,

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title }}</title>
<style>
:root {
--color-bg: #f5f5f5;
--color-surface: #ffffff;
--color-error: #d32f2f;
--color-text: #333333;
--color-text-muted: #666666;
--color-border: #e1e3e6;
--color-warning-bg: #fff3cd;
--color-warning-border: #ffc107;
--color-debug-bg: #e8f5e9;
--color-debug-text: #1b5e20;
--color-prod-bg: #fff3cd;
--color-prod-text: #8a6d3b;
--color-code-bg: #f8f9fa;
--color-vendor-bg: #fafbfc;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--font-mono: 'Courier New', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* {
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
line-height: 1.6;
color: var(--color-text);
max-width: 900px;
margin: 0 auto;
padding: 2rem;
background: var(--color-bg);
}
.error-container {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
}
.error-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.error-title {
color: var(--color-error);
margin: 0;
font-size: 1.75rem;
}
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border: 1px solid rgba(0,0,0,0.08);
}
.badge--debug {
background: var(--color-debug-bg);
color: var(--color-debug-text);
}
.badge--prod {
background: var(--color-prod-bg);
color: var(--color-prod-text);
}
.error-message {
background: var(--color-warning-bg);
border-left: 4px solid var(--color-warning-border);
padding: 1rem 1.25rem;
margin: 1.25rem 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.error-message p {
margin: 0;
}
.notice {
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: #f9fafb;
color: #555;
margin: 1rem 0;
font-size: 0.9rem;
}
.notice--warn {
border-color: #ffe599;
background: #fffaf0;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin: 1.25rem 0;
}
.meta-card {
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #fafbfc;
}
.meta-label {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.meta-value {
font-weight: 600;
font-size: 0.95rem;
word-break: break-word;
}
/* Debug Section Styles */
.debug-section {
background: var(--color-code-bg);
border: 1px solid #dee2e6;
border-radius: var(--radius-md);
padding: 1.25rem;
margin-top: 2rem;
}
.debug-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--color-text);
}
.context-item {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.context-label {
font-weight: 600;
color: var(--color-text-muted);
margin-right: 0.5rem;
}
.context-value {
font-family: var(--font-mono);
font-size: 0.85rem;
}
/* Code Snippet Styles */
.code-context {
margin: 1.25rem 0;
}
.code-context h4 {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
}
/* Stack Trace Styles */
.stack-trace {
margin-top: 1.5rem;
}
.stack-trace__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.stack-trace__header h4 {
margin: 0;
font-size: 0.95rem;
}
.stack-trace__list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
overflow: hidden;
}
.stack-frame {
border-bottom: 1px solid #f0f0f0;
}
.stack-frame:last-child {
border-bottom: none;
}
.stack-frame summary {
padding: 0.6rem 0.75rem;
cursor: pointer;
list-style: none;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.stack-frame summary::-webkit-details-marker {
display: none;
}
.stack-frame summary::before {
content: '\25B6';
font-size: 0.65rem;
color: var(--color-text-muted);
transition: transform 0.15s ease;
}
.stack-frame[open] summary::before {
transform: rotate(90deg);
}
.stack-frame__index {
min-width: 28px;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.stack-frame__call {
font-weight: 600;
font-size: 0.9rem;
}
.stack-frame__location {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.stack-frame__code {
padding: 0.5rem 0.75rem 0.75rem;
border-top: 1px solid #f0f0f0;
background: #fafbfc;
}
.stack-frame--vendor {
background: var(--color-vendor-bg);
}
.stack-frame--vendor summary {
opacity: 0.75;
}
.stack-trace__raw {
display: none;
}
/* Buttons */
.btn {
border: 1px solid #d0d4da;
background: var(--color-surface);
padding: 0.35rem 0.85rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
transition: background-color 0.15s ease;
}
.btn:hover {
background: #eef1f5;
}
.btn--copied {
background: var(--color-debug-bg);
border-color: var(--color-debug-text);
color: var(--color-debug-text);
}
/* Footer */
.error-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text-muted);
text-align: center;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-header">
<h1 class="error-title">{{ $title }}</h1>
<span class="badge badge--{{ $isDebugMode ? 'debug' : 'prod' }}">{{ $isDebugMode ? 'DEBUG' : 'PROD' }}</span>
</div>
<div if="{{ $templateError ?? false }}" class="notice notice--warn">
Template fallback: {{ $templateError }}
</div>
<div class="error-message">
<p>{{ $message }}</p>
</div>
<div class="meta-grid">
<div class="meta-card">
<span class="meta-label">Status</span>
<span class="meta-value">{{ $statusCode }}</span>
</div>
<div class="meta-card">
<span class="meta-label">Exception</span>
<span class="meta-value">{{ $exceptionClass }}</span>
</div>
<div class="meta-card">
<span class="meta-label">Request</span>
<span class="meta-value">{{ $requestMethod }} {{ $requestUri }}</span>
</div>
</div>
<div if="{{ $isDebugMode }}" class="debug-section">
<h3>Debug Information</h3>
<div class="context-item">
<span class="context-label">Exception:</span>
<span class="context-value">{{ $exceptionClass }}</span>
</div>
<div class="context-item">
<span class="context-label">File:</span>
<span class="context-value">{{ $debug['file'] ?? '' }}:{{ $debug['line'] ?? '' }}</span>
</div>
<div class="context-item">
<span class="context-label">Short trace:</span>
<span class="context-value">{{ $debug['trace_short'] ?? '' }}</span>
</div>
<div if="{{ isset($context) }}" class="context-details">
<div if="{{ $context['operation'] ?? '' }}" class="context-item">
<span class="context-label">Operation:</span>
<span class="context-value">{{ $context['operation'] }}</span>
</div>
<div if="{{ $context['component'] ?? '' }}" class="context-item">
<span class="context-label">Component:</span>
<span class="context-value">{{ $context['component'] }}</span>
</div>
<div if="{{ $context['request_id'] ?? '' }}" class="context-item">
<span class="context-label">Request ID:</span>
<span class="context-value">{{ $context['request_id'] }}</span>
</div>
<div if="{{ $context['occurred_at'] ?? '' }}" class="context-item">
<span class="context-label">Occurred At:</span>
<span class="context-value">{{ $context['occurred_at'] }}</span>
</div>
</div>
<div if="{{ $codeSnippet ?? '' }}" class="code-context">
{!! $codeSnippet !!}
</div>
<div if="{{ isset($debug['trace_frames']) }}" class="stack-trace">
<div class="stack-trace__header">
<h4>Stack Trace</h4>
<button type="button" class="btn" id="btn-copy-trace">Copy stack</button>
</div>
<div class="stack-trace__list">
{!! $stackTraceHtml !!}
</div>
<pre id="full-trace-text" class="stack-trace__raw">{{ $debug['trace'] ?? '' }}</pre>
<pre id="full-trace-plain" class="stack-trace__raw">{{ $debug['trace_plain'] ?? '' }}</pre>
</div>
</div>
<div class="error-footer">
Custom PHP Framework
</div>
</div>
<script if="{{ $isDebugMode }}">
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('btn-copy-trace');
if (!btn) return;
btn.addEventListener('click', async function () {
const raw = document.getElementById('full-trace-plain');
if (!raw) return;
try {
await navigator.clipboard.writeText(raw.textContent || '');
btn.textContent = 'Copied!';
btn.classList.add('btn--copied');
setTimeout(() => {
btn.textContent = 'Copy stack';
btn.classList.remove('btn--copied');
}, 1500);
} catch (e) {
btn.textContent = 'Copy failed';
setTimeout(() => btn.textContent = 'Copy stack', 1500);
}
});
});
</script>
</body>
</html>

View File

@@ -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
);
}
}

View File

@@ -49,4 +49,3 @@ final readonly class UserFriendlyMessage
return new self($message, helpText: $helpText);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\FrameworkModule;
use App\Framework\Core\ValueObjects\FrameworkModuleRegistry;
use App\Framework\Core\ValueObjects\PhpNamespace;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('FrameworkModuleRegistry', function () {
it('creates registry with variadic modules', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Database', $basePath),
FrameworkModule::create('Cache', $basePath)
);
expect($registry->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);
});
});
});

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\FrameworkModule;
use App\Framework\Core\ValueObjects\PhpNamespace;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('FrameworkModule', function () {
it('creates module with valid name', function () {
$path = FilePath::create('/var/www/html/src/Framework/Http');
$module = new FrameworkModule('Http', $path);
expect($module->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');
});
});

View File

@@ -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('<!DOCTYPE html>');
@@ -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