fix(Console): add void as valid return type for command methods
The MethodSignatureAnalyzer was rejecting command methods with void return type, causing the schedule:run command to fail validation.
This commit is contained in:
@@ -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"}}
|
||||||
@@ -159,10 +159,81 @@ function processUser(User $user): UserProfile
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Available Value Objects**:
|
**Available Value Objects**:
|
||||||
- Core: Email, RGBColor, Url, Hash, Version, Coordinates
|
- Core: Email, RGBColor, Url, Hash, Version, Coordinates, ClassName, PhpNamespace
|
||||||
- HTTP: FlashMessage, ValidationError, RouteParameters
|
- HTTP: FlashMessage, ValidationError, RouteParameters
|
||||||
- Security: OWASPEventIdentifier, MaskedEmail, ThreatLevel
|
- Security: OWASPEventIdentifier, MaskedEmail, ThreatLevel
|
||||||
- Performance: Measurement, MetricContext, MemorySummary
|
- 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
|
## Middleware System
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,13 @@ final readonly class NavigationItem
|
|||||||
|
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
{
|
{
|
||||||
|
$icon = $this->getIcon();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'url' => $this->url,
|
'url' => $this->url,
|
||||||
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
|
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
|
||||||
|
'icon_html' => $icon?->toHtml('admin-nav__icon') ?? '',
|
||||||
'is_active' => $this->isActive,
|
'is_active' => $this->isActive,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ final readonly class NavigationSection
|
|||||||
|
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
{
|
{
|
||||||
|
$icon = $this->getIcon();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'section' => $this->name,
|
'section' => $this->name,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
@@ -68,6 +70,7 @@ final readonly class NavigationSection
|
|||||||
$this->items
|
$this->items
|
||||||
),
|
),
|
||||||
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
|
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
|
||||||
|
'icon_html' => $icon?->toHtml('admin-nav__section-icon') ?? '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -330,8 +330,9 @@ final readonly class MethodSignatureAnalyzer
|
|||||||
$returnType = $method->getReturnType();
|
$returnType = $method->getReturnType();
|
||||||
if ($returnType instanceof ReflectionNamedType) {
|
if ($returnType instanceof ReflectionNamedType) {
|
||||||
$returnTypeName = $returnType->getName();
|
$returnTypeName = $returnType->getName();
|
||||||
// Accept: int, ExitCode, ActionResult, or array
|
// Accept: void, int, ExitCode, ActionResult, or array
|
||||||
$validReturnTypes = [
|
$validReturnTypes = [
|
||||||
|
'void',
|
||||||
'int',
|
'int',
|
||||||
ExitCode::class,
|
ExitCode::class,
|
||||||
'App\Framework\MagicLinks\Actions\ActionResult',
|
'App\Framework\MagicLinks\Actions\ActionResult',
|
||||||
|
|||||||
116
src/Framework/Core/ValueObjects/FrameworkModule.php
Normal file
116
src/Framework/Core/ValueObjects/FrameworkModule.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php
Normal file
208
src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,4 +20,3 @@ final readonly class DiscoveryWarning
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,4 +101,3 @@ final class DiscoveryWarningAggregator
|
|||||||
return $this->warningsByFile !== [];
|
return $this->warningsByFile !== [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,3 @@ final readonly class DiscoveryWarningGroup
|
|||||||
return count($this->warnings);
|
return count($this->warnings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\ExceptionHandling\Audit;
|
namespace App\Framework\ExceptionHandling\Audit;
|
||||||
|
|
||||||
use App\Framework\Audit\AuditLogger;
|
use App\Framework\Audit\AuditLogger;
|
||||||
use App\Framework\Audit\ValueObjects\AuditEntry;
|
|
||||||
use App\Framework\Audit\ValueObjects\AuditableAction;
|
use App\Framework\Audit\ValueObjects\AuditableAction;
|
||||||
|
use App\Framework\Audit\ValueObjects\AuditEntry;
|
||||||
use App\Framework\DateTime\Clock;
|
use App\Framework\DateTime\Clock;
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
@@ -44,7 +44,7 @@ final readonly class ExceptionAuditLogger
|
|||||||
$context = $context ?? $this->getContext($exception);
|
$context = $context ?? $this->getContext($exception);
|
||||||
|
|
||||||
// Skip if not auditable
|
// Skip if not auditable
|
||||||
if (!$this->isAuditable($context)) {
|
if (! $this->isAuditable($context)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +192,7 @@ final readonly class ExceptionAuditLogger
|
|||||||
// Remove common suffixes
|
// Remove common suffixes
|
||||||
$component = $context->component;
|
$component = $context->component;
|
||||||
$component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component);
|
$component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component);
|
||||||
|
|
||||||
return strtolower($component);
|
return strtolower($component);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ final readonly class ExceptionAuditLogger
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Add context data
|
// Add context data
|
||||||
if (!empty($context->data)) {
|
if (! empty($context->data)) {
|
||||||
$metadata['context_data'] = $context->data;
|
$metadata['context_data'] = $context->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +268,7 @@ final readonly class ExceptionAuditLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add tags
|
// Add tags
|
||||||
if (!empty($context->tags)) {
|
if (! empty($context->tags)) {
|
||||||
$metadata['tags'] = $context->tags;
|
$metadata['tags'] = $context->tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +284,7 @@ final readonly class ExceptionAuditLogger
|
|||||||
// Merge with existing metadata (but exclude internal fields)
|
// Merge with existing metadata (but exclude internal fields)
|
||||||
$excludeKeys = ['auditable', 'audit_action', 'entity_type'];
|
$excludeKeys = ['auditable', 'audit_action', 'entity_type'];
|
||||||
foreach ($context->metadata as $key => $value) {
|
foreach ($context->metadata as $key => $value) {
|
||||||
if (!in_array($key, $excludeKeys, true)) {
|
if (! in_array($key, $excludeKeys, true)) {
|
||||||
$metadata[$key] = $value;
|
$metadata[$key] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,4 +307,3 @@ final readonly class ExceptionAuditLogger
|
|||||||
return $this->contextProvider->get($exception);
|
return $this->contextProvider->get($exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -32,4 +33,3 @@ final readonly class BasicErrorHandler implements ErrorHandlerInterface
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -31,4 +32,3 @@ final readonly class BasicGlobalExceptionHandler implements ExceptionHandler
|
|||||||
file_put_contents('php://stderr', $errorOutput);
|
file_put_contents('php://stderr', $errorOutput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -16,7 +17,7 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
|
|||||||
{
|
{
|
||||||
$last = error_get_last();
|
$last = error_get_last();
|
||||||
|
|
||||||
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
|
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,4 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ final readonly class ExceptionContextBuilder
|
|||||||
if ($baseContext !== null) {
|
if ($baseContext !== null) {
|
||||||
return $this->mergeContexts($cached, $baseContext);
|
return $this->mergeContexts($cached, $baseContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +110,7 @@ final readonly class ExceptionContextBuilder
|
|||||||
// Extract session ID
|
// Extract session ID
|
||||||
if (property_exists($request, 'session') && $request->session !== null) {
|
if (property_exists($request, 'session') && $request->session !== null) {
|
||||||
$sessionId = $request->session->id->toString();
|
$sessionId = $request->session->id->toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$context = $context->withSessionId(SessionId::fromString($sessionId));
|
$context = $context->withSessionId(SessionId::fromString($sessionId));
|
||||||
} catch (\InvalidArgumentException) {
|
} catch (\InvalidArgumentException) {
|
||||||
@@ -162,13 +164,13 @@ final readonly class ExceptionContextBuilder
|
|||||||
if ($base->component !== null && $merged->component === null) {
|
if ($base->component !== null && $merged->component === null) {
|
||||||
$merged = $merged->withOperation($merged->operation ?? '', $base->component);
|
$merged = $merged->withOperation($merged->operation ?? '', $base->component);
|
||||||
}
|
}
|
||||||
if (!empty($base->data)) {
|
if (! empty($base->data)) {
|
||||||
$merged = $merged->addData($base->data);
|
$merged = $merged->addData($base->data);
|
||||||
}
|
}
|
||||||
if (!empty($base->debug)) {
|
if (! empty($base->debug)) {
|
||||||
$merged = $merged->addDebug($base->debug);
|
$merged = $merged->addDebug($base->debug);
|
||||||
}
|
}
|
||||||
if (!empty($base->metadata)) {
|
if (! empty($base->metadata)) {
|
||||||
$merged = $merged->addMetadata($base->metadata);
|
$merged = $merged->addMetadata($base->metadata);
|
||||||
}
|
}
|
||||||
if ($base->userId !== null) {
|
if ($base->userId !== null) {
|
||||||
@@ -186,7 +188,7 @@ final readonly class ExceptionContextBuilder
|
|||||||
if ($base->userAgent !== null) {
|
if ($base->userAgent !== null) {
|
||||||
$merged = $merged->withUserAgent($base->userAgent);
|
$merged = $merged->withUserAgent($base->userAgent);
|
||||||
}
|
}
|
||||||
if (!empty($base->tags)) {
|
if (! empty($base->tags)) {
|
||||||
$merged = $merged->withTags(...array_merge($merged->tags, $base->tags));
|
$merged = $merged->withTags(...array_merge($merged->tags, $base->tags));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +263,7 @@ final readonly class ExceptionContextBuilder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Add scope tags
|
// Add scope tags
|
||||||
if (!empty($scopeContext->tags)) {
|
if (! empty($scopeContext->tags)) {
|
||||||
$context = $context->withTags(...$scopeContext->tags);
|
$context = $context->withTags(...$scopeContext->tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,4 +301,3 @@ final readonly class ExceptionContextBuilder
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ final readonly class ExceptionContextCache
|
|||||||
$result = $this->cache->get($cacheKey);
|
$result = $this->cache->get($cacheKey);
|
||||||
$item = $result->getItem($cacheKey);
|
$item = $result->getItem($cacheKey);
|
||||||
|
|
||||||
if (!$item->isHit) {
|
if (! $item->isHit) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,4 +210,3 @@ final readonly class ExceptionContextCache
|
|||||||
$this->cache->set($cacheItem);
|
$this->cache->set($cacheItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,4 +37,3 @@ final readonly class ExceptionCorrelation
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ final readonly class ExceptionCorrelationEngine
|
|||||||
// Prefer Request-ID, then Session-ID, then User-ID
|
// Prefer Request-ID, then Session-ID, then User-ID
|
||||||
if ($context->requestId !== null) {
|
if ($context->requestId !== null) {
|
||||||
$requestId = is_string($context->requestId) ? $context->requestId : $context->requestId->toString();
|
$requestId = is_string($context->requestId) ? $context->requestId : $context->requestId->toString();
|
||||||
|
|
||||||
return 'request:' . $requestId;
|
return 'request:' . $requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($context->sessionId !== null) {
|
if ($context->sessionId !== null) {
|
||||||
$sessionId = is_string($context->sessionId) ? $context->sessionId : $context->sessionId->toString();
|
$sessionId = is_string($context->sessionId) ? $context->sessionId : $context->sessionId->toString();
|
||||||
|
|
||||||
return 'session:' . $sessionId;
|
return 'session:' . $sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ final readonly class ExceptionCorrelationEngine
|
|||||||
$result = $this->cache->get($cacheKey);
|
$result = $this->cache->get($cacheKey);
|
||||||
$item = $result->getItem($cacheKey);
|
$item = $result->getItem($cacheKey);
|
||||||
|
|
||||||
if (!$item->isHit) {
|
if (! $item->isHit) {
|
||||||
return new ExceptionCorrelation(correlationKey: '');
|
return new ExceptionCorrelation(correlationKey: '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,4 +130,3 @@ final readonly class ExceptionCorrelationEngine
|
|||||||
$this->cache->set($cacheItem);
|
$this->cache->set($cacheItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
use App\Framework\Core\ValueObjects\LineNumber;
|
use App\Framework\Core\ValueObjects\LineNumber;
|
||||||
use ErrorException;
|
|
||||||
|
|
||||||
final readonly class ErrorContext
|
final readonly class ErrorContext
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,8 @@ final readonly class ErrorContext
|
|||||||
public ?string $file = null,
|
public ?string $file = null,
|
||||||
public ?LineNumber $line = null,
|
public ?LineNumber $line = null,
|
||||||
public bool $isSuppressed = false,
|
public bool $isSuppressed = false,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public static function create(
|
public static function create(
|
||||||
int $severity,
|
int $severity,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
enum ErrorDecision
|
enum ErrorDecision
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -9,9 +10,9 @@ use ErrorException;
|
|||||||
final readonly class ErrorHandler implements ErrorHandlerInterface
|
final readonly class ErrorHandler implements ErrorHandlerInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy,
|
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy(),
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ErrorException
|
* @throws ErrorException
|
||||||
@@ -42,6 +43,6 @@ final readonly class ErrorHandler implements ErrorHandlerInterface
|
|||||||
|
|
||||||
private function isSuppressed($severity): bool
|
private function isSuppressed($severity): bool
|
||||||
{
|
{
|
||||||
return !(error_reporting() & $severity);
|
return ! (error_reporting() & $severity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
interface ErrorHandlerInterface
|
interface ErrorHandlerInterface
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
declare(strict_types=1);
|
||||||
|
|
||||||
use ErrorException;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
interface ErrorHandlerStrategy
|
interface ErrorHandlerStrategy
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ final readonly class ErrorHandlingConfig
|
|||||||
public bool $debugMode = false,
|
public bool $debugMode = false,
|
||||||
public bool $logErrors = true,
|
public bool $logErrors = true,
|
||||||
public bool $displayErrors = false
|
public bool $displayErrors = false
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create config from EnvironmentType
|
* Create config from EnvironmentType
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -10,9 +11,8 @@ use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
|||||||
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
|
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
|
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\Renderers\ResponseErrorRenderer;
|
||||||
|
use App\Framework\ExceptionHandling\Reporter\Reporter;
|
||||||
use App\Framework\Http\HttpResponse;
|
use App\Framework\Http\HttpResponse;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -61,12 +61,12 @@ final readonly class ErrorKernel
|
|||||||
$shouldSkipAudit = $this->rateLimiter?->shouldSkipAudit($e, $exceptionContext) ?? false;
|
$shouldSkipAudit = $this->rateLimiter?->shouldSkipAudit($e, $exceptionContext) ?? false;
|
||||||
|
|
||||||
// Log exception to audit system if auditable and not rate limited
|
// 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);
|
$this->auditLogger->logIfAuditable($e, $exceptionContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log exception if not rate limited and reporter is available
|
// Log exception if not rate limited and reporter is available
|
||||||
if (!$shouldSkipLogging && $this->reporter !== null) {
|
if (! $shouldSkipLogging && $this->reporter !== null) {
|
||||||
$this->reporter->report($e);
|
$this->reporter->report($e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +85,7 @@ final readonly class ErrorKernel
|
|||||||
// Handle based on context
|
// Handle based on context
|
||||||
if ($executionContext->isCli()) {
|
if ($executionContext->isCli()) {
|
||||||
$this->handleCliException($e);
|
$this->handleCliException($e);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ final readonly class ErrorKernel
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Ensure we return HttpResponse (type safety)
|
// Ensure we return HttpResponse (type safety)
|
||||||
if (!$result instanceof HttpResponse) {
|
if (! $result instanceof HttpResponse) {
|
||||||
throw new \RuntimeException('HTTP renderer must return HttpResponse');
|
throw new \RuntimeException('HTTP renderer must return HttpResponse');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,4 +222,3 @@ final readonly class ErrorKernel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -35,6 +36,7 @@ final readonly class ErrorRendererFactory
|
|||||||
if ($this->executionContext->isCli()) {
|
if ($this->executionContext->isCli()) {
|
||||||
// ConsoleOutput should always be available in CLI context
|
// ConsoleOutput should always be available in CLI context
|
||||||
$output = $this->consoleOutput ?? new ConsoleOutput();
|
$output = $this->consoleOutput ?? new ConsoleOutput();
|
||||||
|
|
||||||
return new ConsoleErrorRenderer($output);
|
return new ConsoleErrorRenderer($output);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ final readonly class ErrorRendererFactory
|
|||||||
public function createHttpRenderer(?bool $debugMode = null): ResponseErrorRenderer
|
public function createHttpRenderer(?bool $debugMode = null): ResponseErrorRenderer
|
||||||
{
|
{
|
||||||
$debugMode = $debugMode ?? $this->config->debugMode;
|
$debugMode = $debugMode ?? $this->config->debugMode;
|
||||||
|
|
||||||
return new ResponseErrorRenderer($this->engine, $debugMode);
|
return new ResponseErrorRenderer($this->engine, $debugMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
final class ErrorScopeContext
|
final class ErrorScopeContext
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,4 +85,3 @@ enum ErrorSeverityType: int
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ final readonly class ExceptionHandlerManagerFactory
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Container $container
|
private Container $container
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create and register ExceptionHandlerManager for current execution context
|
* Create and register ExceptionHandlerManager for current execution context
|
||||||
@@ -62,4 +63,3 @@ final readonly class ExceptionHandlerManagerFactory
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ namespace App\Framework\ExceptionHandling;
|
|||||||
use App\Framework\Config\TypedConfiguration;
|
use App\Framework\Config\TypedConfiguration;
|
||||||
use App\Framework\Console\ConsoleOutput;
|
use App\Framework\Console\ConsoleOutput;
|
||||||
use App\Framework\Context\ExecutionContext;
|
use App\Framework\Context\ExecutionContext;
|
||||||
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
|
||||||
use App\Framework\DI\Container;
|
use App\Framework\DI\Container;
|
||||||
use App\Framework\DI\Initializer;
|
use App\Framework\DI\Initializer;
|
||||||
|
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||||
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
|
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
|
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
|
||||||
use App\Framework\ExceptionHandling\Reporter\LogReporter;
|
use App\Framework\ExceptionHandling\Reporter\LogReporter;
|
||||||
use App\Framework\ExceptionHandling\Reporter\Reporter;
|
use App\Framework\ExceptionHandling\Reporter\Reporter;
|
||||||
use App\Framework\ExceptionHandling\Reporter\ReporterRegistry;
|
use App\Framework\ExceptionHandling\Reporter\ReporterRegistry;
|
||||||
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
|
|
||||||
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
|
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
|
||||||
use App\Framework\Logging\Logger;
|
use App\Framework\Logging\Logger;
|
||||||
use App\Framework\View\Engine;
|
use App\Framework\View\Engine;
|
||||||
@@ -99,7 +99,7 @@ final readonly class ExceptionHandlingInitializer
|
|||||||
$container->bind(ErrorHandler::class, new ErrorHandler(strategy: $strategy));
|
$container->bind(ErrorHandler::class, new ErrorHandler(strategy: $strategy));
|
||||||
|
|
||||||
// ExceptionContextProvider - bind as singleton if not already bound
|
// ExceptionContextProvider - bind as singleton if not already bound
|
||||||
if (!$container->has(ExceptionContextProvider::class)) {
|
if (! $container->has(ExceptionContextProvider::class)) {
|
||||||
$container->singleton(ExceptionContextProvider::class, new ExceptionContextProvider());
|
$container->singleton(ExceptionContextProvider::class, new ExceptionContextProvider());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ final readonly class ExceptionHandlingInitializer
|
|||||||
private function registerManager(Container $container, ExceptionHandlerManager $manager): void
|
private function registerManager(Container $container, ExceptionHandlerManager $manager): void
|
||||||
{
|
{
|
||||||
// Store manager in container for potential later use
|
// Store manager in container for potential later use
|
||||||
if (!$container->has(ExceptionHandlerManager::class)) {
|
if (! $container->has(ExceptionHandlerManager::class)) {
|
||||||
$container->instance(ExceptionHandlerManager::class, $manager);
|
$container->instance(ExceptionHandlerManager::class, $manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ final readonly class ExceptionFactory
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private ExceptionContextProvider $contextProvider,
|
private ExceptionContextProvider $contextProvider,
|
||||||
private ErrorScope $errorScope
|
private ErrorScope $errorScope
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create exception with context
|
* Create exception with context
|
||||||
@@ -300,7 +301,7 @@ final readonly class ExceptionFactory
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add scope tags
|
// Add scope tags
|
||||||
if (!empty($scopeContext->tags)) {
|
if (! empty($scopeContext->tags)) {
|
||||||
$enriched = $enriched->withTags(...$scopeContext->tags);
|
$enriched = $enriched->withTags(...$scopeContext->tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -16,7 +17,7 @@ final class FatalErrorTypes
|
|||||||
E_PARSE,
|
E_PARSE,
|
||||||
E_CORE_ERROR,
|
E_CORE_ERROR,
|
||||||
E_COMPILE_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);
|
return in_array($type, self::FATAL_TYPES, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -7,7 +8,8 @@ final readonly class GlobalExceptionHandler implements ExceptionHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ErrorKernel $errorKernel
|
private ErrorKernel $errorKernel
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(\Throwable $throwable): void
|
public function handle(\Throwable $throwable): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Framework\Health\HealthCheckResult;
|
|||||||
final readonly class ExceptionHealthChecker implements HealthCheckInterface
|
final readonly class ExceptionHealthChecker implements HealthCheckInterface
|
||||||
{
|
{
|
||||||
public readonly string $name;
|
public readonly string $name;
|
||||||
|
|
||||||
public readonly int $timeout;
|
public readonly int $timeout;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,4 +106,3 @@ final readonly class ExceptionHealthChecker implements HealthCheckInterface
|
|||||||
return \App\Framework\Health\HealthCheckCategory::APPLICATION;
|
return \App\Framework\Health\HealthCheckCategory::APPLICATION;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,4 +81,3 @@ final readonly class ExceptionLocalizer
|
|||||||
return array_unique($chain);
|
return array_unique($chain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,3 @@ final readonly class ExceptionMetrics
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ final readonly class ExceptionMetricsCollector
|
|||||||
private function getMetric(string $metricName): int
|
private function getMetric(string $metricName): int
|
||||||
{
|
{
|
||||||
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $metricName);
|
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $metricName);
|
||||||
|
|
||||||
return $this->getMetricValue($cacheKey);
|
return $this->getMetricValue($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +112,12 @@ final readonly class ExceptionMetricsCollector
|
|||||||
$result = $this->cache->get($cacheKey);
|
$result = $this->cache->get($cacheKey);
|
||||||
$item = $result->getItem($cacheKey);
|
$item = $result->getItem($cacheKey);
|
||||||
|
|
||||||
if (!$item->isHit) {
|
if (! $item->isHit) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$value = $item->value;
|
$value = $item->value;
|
||||||
|
|
||||||
return is_int($value) ? $value : 0;
|
return is_int($value) ? $value : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +165,7 @@ final readonly class ExceptionMetricsCollector
|
|||||||
$result = $this->cache->get($cacheKey);
|
$result = $this->cache->get($cacheKey);
|
||||||
$item = $result->getItem($cacheKey);
|
$item = $result->getItem($cacheKey);
|
||||||
|
|
||||||
if (!$item->isHit || !is_array($item->value)) {
|
if (! $item->isHit || ! is_array($item->value)) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,4 +177,3 @@ final readonly class ExceptionMetricsCollector
|
|||||||
return array_sum($times) / count($times);
|
return array_sum($times) / count($times);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ final readonly class PrometheusExporter
|
|||||||
{
|
{
|
||||||
// Replace invalid characters
|
// Replace invalid characters
|
||||||
$sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
|
$sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
|
||||||
|
|
||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,4 +180,3 @@ final readonly class ErrorScopeMiddleware implements HttpMiddleware
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,10 @@ final readonly class ExceptionPattern
|
|||||||
'fingerprint' => $this->fingerprint,
|
'fingerprint' => $this->fingerprint,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'fix_suggestions' => array_map(
|
'fix_suggestions' => array_map(
|
||||||
fn(FixSuggestion $suggestion) => $suggestion->toArray(),
|
fn (FixSuggestion $suggestion) => $suggestion->toArray(),
|
||||||
$this->fixSuggestions
|
$this->fixSuggestions
|
||||||
),
|
),
|
||||||
'occurrence_count' => $this->occurrenceCount,
|
'occurrence_count' => $this->occurrenceCount,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final readonly class ExceptionPatternDetector
|
|||||||
if (isset($this->knowledgeBase[$exceptionClass])) {
|
if (isset($this->knowledgeBase[$exceptionClass])) {
|
||||||
$patternData = $this->knowledgeBase[$exceptionClass];
|
$patternData = $this->knowledgeBase[$exceptionClass];
|
||||||
$fixSuggestions = array_map(
|
$fixSuggestions = array_map(
|
||||||
fn(array $fix) => new FixSuggestion(
|
fn (array $fix) => new FixSuggestion(
|
||||||
title: $fix['title'] ?? '',
|
title: $fix['title'] ?? '',
|
||||||
description: $fix['description'] ?? '',
|
description: $fix['description'] ?? '',
|
||||||
codeExample: $fix['code'] ?? null,
|
codeExample: $fix['code'] ?? null,
|
||||||
@@ -66,4 +66,3 @@ final readonly class ExceptionPatternDetector
|
|||||||
return $patterns;
|
return $patterns;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,3 @@ final readonly class FixSuggestion
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,3 @@ final readonly class ExceptionPerformanceMetrics
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,4 +108,3 @@ final readonly class ExceptionPerformanceTracker
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,4 +143,3 @@ final readonly class ExceptionFingerprint
|
|||||||
return $this->hash;
|
return $this->hash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,4 +106,3 @@ final readonly class ExceptionRateLimitConfig
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Framework\ExceptionHandling\RateLimit;
|
|||||||
use App\Framework\Cache\Cache;
|
use App\Framework\Cache\Cache;
|
||||||
use App\Framework\Cache\CacheItem;
|
use App\Framework\Cache\CacheItem;
|
||||||
use App\Framework\Cache\CacheKey;
|
use App\Framework\Cache\CacheKey;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ final readonly class ExceptionRateLimiter
|
|||||||
*/
|
*/
|
||||||
public function shouldProcess(Throwable $exception, ?ExceptionContextData $context = null): bool
|
public function shouldProcess(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||||
{
|
{
|
||||||
if (!$this->config->enabled) {
|
if (! $this->config->enabled) {
|
||||||
return true; // Rate limiting disabled, always process
|
return true; // Rate limiting disabled, always process
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +69,11 @@ final readonly class ExceptionRateLimiter
|
|||||||
*/
|
*/
|
||||||
public function shouldSkipLogging(Throwable $exception, ?ExceptionContextData $context = null): bool
|
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 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
|
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 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
|
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
|
return true; // Always track if not enabled or tracking not disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +115,7 @@ final readonly class ExceptionRateLimiter
|
|||||||
: ExceptionFingerprint::fromException($exception);
|
: ExceptionFingerprint::fromException($exception);
|
||||||
|
|
||||||
$cacheKey = $this->buildCacheKey($fingerprint);
|
$cacheKey = $this->buildCacheKey($fingerprint);
|
||||||
|
|
||||||
return $this->getCachedCount($cacheKey);
|
return $this->getCachedCount($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,11 +135,12 @@ final readonly class ExceptionRateLimiter
|
|||||||
$result = $this->cache->get($cacheKey);
|
$result = $this->cache->get($cacheKey);
|
||||||
$item = $result->getItem($cacheKey);
|
$item = $result->getItem($cacheKey);
|
||||||
|
|
||||||
if (!$item->isHit) {
|
if (! $item->isHit) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$value = $item->value;
|
$value = $item->value;
|
||||||
|
|
||||||
return is_int($value) ? $value : 0;
|
return is_int($value) ? $value : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,4 +180,3 @@ final readonly class ExceptionRateLimiter
|
|||||||
$this->cache->forget($cacheKey);
|
$this->cache->forget($cacheKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\ExceptionHandling\Recovery;
|
namespace App\Framework\ExceptionHandling\Recovery;
|
||||||
|
|
||||||
use App\Framework\Exception\ExceptionMetadata;
|
use App\Framework\Exception\ExceptionMetadata;
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +33,7 @@ final readonly class ExceptionRecoveryManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if exception is retryable (implements marker interface or is in whitelist)
|
// Check if exception is retryable (implements marker interface or is in whitelist)
|
||||||
if (!$this->isRetryable($exception)) {
|
if (! $this->isRetryable($exception)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,4 +101,3 @@ final readonly class ExceptionRecoveryManager
|
|||||||
interface RetryableException
|
interface RetryableException
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,4 +31,3 @@ enum RetryStrategy: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ConsoleOutput $output
|
private ConsoleOutput $output
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function canRender(\Throwable $exception): bool
|
public function canRender(\Throwable $exception): bool
|
||||||
{
|
{
|
||||||
@@ -107,13 +108,13 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
|||||||
*/
|
*/
|
||||||
private function formatFileLink(string $filePath, int $line): string
|
private function formatFileLink(string $filePath, int $line): string
|
||||||
{
|
{
|
||||||
if (!$this->isPhpStorm()) {
|
if (! $this->isPhpStorm()) {
|
||||||
return $filePath . ':' . $line;
|
return $filePath . ':' . $line;
|
||||||
}
|
}
|
||||||
|
|
||||||
$linkFormatter = $this->output->getLinkFormatter();
|
$linkFormatter = $this->output->getLinkFormatter();
|
||||||
$relativePath = PhpStormDetector::getRelativePath($filePath);
|
$relativePath = PhpStormDetector::getRelativePath($filePath);
|
||||||
|
|
||||||
return $linkFormatter->createFileLinkWithLine($filePath, $line, $relativePath . ':' . $line);
|
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);
|
$indexLabel = str_pad((string) $index, $indexWidth, ' ', STR_PAD_LEFT);
|
||||||
$baseFormat = sprintf('#%s %s', $indexLabel, $item->formatForDisplay());
|
$baseFormat = sprintf('#%s %s', $indexLabel, $item->formatForDisplay());
|
||||||
|
|
||||||
// If PhpStorm is detected, replace file:line with clickable link
|
// If PhpStorm is detected, replace file:line with clickable link
|
||||||
if (!$this->isPhpStorm()) {
|
if (! $this->isPhpStorm()) {
|
||||||
return $baseFormat;
|
return $baseFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +136,12 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
|||||||
$linkFormatter = $this->output->getLinkFormatter();
|
$linkFormatter = $this->output->getLinkFormatter();
|
||||||
$relativePath = PhpStormDetector::getRelativePath($item->file);
|
$relativePath = PhpStormDetector::getRelativePath($item->file);
|
||||||
$fileLink = $linkFormatter->createFileLinkWithLine($item->file, $item->line, $relativePath . ':' . $item->line);
|
$fileLink = $linkFormatter->createFileLinkWithLine($item->file, $item->line, $relativePath . ':' . $item->line);
|
||||||
|
|
||||||
// Replace the file:line part in the formatted string
|
// Replace the file:line part in the formatted string
|
||||||
// Use the short file path that's actually in the formatted string
|
// Use the short file path that's actually in the formatted string
|
||||||
$shortFile = $item->getShortFile();
|
$shortFile = $item->getShortFile();
|
||||||
$fileLocation = $shortFile . ':' . $item->line;
|
$fileLocation = $shortFile . ':' . $item->line;
|
||||||
|
|
||||||
// Find and replace the file location in the base format
|
// Find and replace the file location in the base format
|
||||||
$position = strpos($baseFormat, $fileLocation);
|
$position = strpos($baseFormat, $fileLocation);
|
||||||
if ($position !== false) {
|
if ($position !== false) {
|
||||||
@@ -151,4 +152,3 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
|||||||
return $baseFormat;
|
return $baseFormat;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
private Engine $engine,
|
private Engine $engine,
|
||||||
private bool $isDebugMode = false,
|
private bool $isDebugMode = false,
|
||||||
private ?ExceptionMessageTranslator $messageTranslator = null
|
private ?ExceptionMessageTranslator $messageTranslator = null
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this renderer can handle the exception
|
* 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
|
// Get user-friendly message if translator is available
|
||||||
$context = $contextProvider?->get($exception);
|
$context = $contextProvider?->get($exception);
|
||||||
$userMessage = $this->messageTranslator?->translate($exception, $context)
|
$userMessage = $this->messageTranslator?->translate($exception, $context)
|
||||||
?? new \App\Framework\ExceptionHandling\Translation\UserFriendlyMessage(
|
?? new \App\Framework\ExceptionHandling\Translation\UserFriendlyMessage(
|
||||||
message: $this->isDebugMode
|
message: $this->isDebugMode
|
||||||
? $exception->getMessage()
|
? $exception->getMessage()
|
||||||
@@ -85,7 +86,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
'message' => $userMessage->message,
|
'message' => $userMessage->message,
|
||||||
'type' => $this->isDebugMode ? $this->getShortClassName(get_class($exception)) : 'ServerError',
|
'type' => $this->isDebugMode ? $this->getShortClassName(get_class($exception)) : 'ServerError',
|
||||||
'code' => $exception->getCode(),
|
'code' => $exception->getCode(),
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($userMessage->title !== null) {
|
if ($userMessage->title !== null) {
|
||||||
@@ -136,13 +137,14 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
?ExceptionContextProvider $contextProvider
|
?ExceptionContextProvider $contextProvider
|
||||||
): HttpResponse {
|
): HttpResponse {
|
||||||
$statusCode = $this->getHttpStatusCode($exception);
|
$statusCode = $this->getHttpStatusCode($exception);
|
||||||
|
$templateError = null;
|
||||||
|
|
||||||
// Try to render using template system
|
// 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
|
// Fallback to simple HTML if template rendering fails
|
||||||
if ($html === null) {
|
if ($html === null) {
|
||||||
$html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode);
|
$html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode, $templateError);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HttpResponse(
|
return new HttpResponse(
|
||||||
@@ -163,7 +165,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
private function renderWithTemplate(
|
private function renderWithTemplate(
|
||||||
\Throwable $exception,
|
\Throwable $exception,
|
||||||
?ExceptionContextProvider $contextProvider,
|
?ExceptionContextProvider $contextProvider,
|
||||||
int $statusCode
|
int $statusCode,
|
||||||
|
?string &$templateError = null
|
||||||
): ?string {
|
): ?string {
|
||||||
try {
|
try {
|
||||||
// Determine template name based on status code
|
// Determine template name based on status code
|
||||||
@@ -194,6 +197,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
));
|
));
|
||||||
|
|
||||||
// Return null to trigger fallback HTML generation
|
// Return null to trigger fallback HTML generation
|
||||||
|
$templateError = $e->getMessage();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +233,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
: 'An error occurred while processing your request.',
|
: 'An error occurred while processing your request.',
|
||||||
'exceptionClass' => $this->getShortClassName(get_class($exception)),
|
'exceptionClass' => $this->getShortClassName(get_class($exception)),
|
||||||
'isDebugMode' => $this->isDebugMode,
|
'isDebugMode' => $this->isDebugMode,
|
||||||
|
'requestMethod' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||||
|
'requestUri' => $_SERVER['REQUEST_URI'] ?? '/',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add debug information if enabled
|
// Add debug information if enabled
|
||||||
@@ -240,8 +246,18 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
'trace' => $stackTrace->formatForHtml(),
|
'trace' => $stackTrace->formatForHtml(),
|
||||||
'trace_short' => $stackTrace->formatShort(),
|
'trace_short' => $stackTrace->formatShort(),
|
||||||
'trace_frames' => $stackTrace->toArray(),
|
'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
|
// Add context from WeakMap if available
|
||||||
if ($contextProvider !== null) {
|
if ($contextProvider !== null) {
|
||||||
$context = $contextProvider->get($exception);
|
$context = $contextProvider->get($exception);
|
||||||
@@ -265,7 +281,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
private function generateFallbackHtml(
|
private function generateFallbackHtml(
|
||||||
\Throwable $exception,
|
\Throwable $exception,
|
||||||
?ExceptionContextProvider $contextProvider,
|
?ExceptionContextProvider $contextProvider,
|
||||||
int $statusCode
|
int $statusCode,
|
||||||
|
?string $templateError = null
|
||||||
): string {
|
): string {
|
||||||
// HTML-encode all variables for security
|
// HTML-encode all variables for security
|
||||||
$title = htmlspecialchars($this->getErrorTitle($statusCode), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
$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,
|
ENT_QUOTES | ENT_HTML5,
|
||||||
'UTF-8'
|
'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 = '';
|
$debugInfo = '';
|
||||||
if ($this->isDebugMode) {
|
if ($this->isDebugMode) {
|
||||||
@@ -294,20 +320,39 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #333;
|
||||||
max-width: 800px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
.error-container {
|
.error-container {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 2rem;
|
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 {
|
h1 {
|
||||||
color: #d32f2f;
|
color: #d32f2f;
|
||||||
margin-top: 0;
|
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 {
|
.error-message {
|
||||||
background: #fff3cd;
|
background: #fff3cd;
|
||||||
@@ -315,6 +360,36 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 1rem 0;
|
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 {
|
.debug-info {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
@@ -399,10 +474,25 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<h1>{$title}</h1>
|
<h1>{$title} {$debugBadge}</h1>
|
||||||
|
{$templateNotice}
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
<p>{$message}</p>
|
<p>{$message}</p>
|
||||||
</div>
|
</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}
|
{$debugInfo}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -478,7 +568,7 @@ HTML;
|
|||||||
<div class="stack-trace__list">
|
<div class="stack-trace__list">
|
||||||
{$renderedFrames}
|
{$renderedFrames}
|
||||||
</div>
|
</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>
|
<pre id="full-trace-plain" style="display:none">{$tracePlain}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -582,6 +672,7 @@ HTML;
|
|||||||
private function getShortClassName(string $fullClassName): string
|
private function getShortClassName(string $fullClassName): string
|
||||||
{
|
{
|
||||||
$parts = explode('\\', $fullClassName);
|
$parts = explode('\\', $fullClassName);
|
||||||
|
|
||||||
return end($parts);
|
return end($parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +681,7 @@ HTML;
|
|||||||
*/
|
*/
|
||||||
private function getCodeSnippet(string $file, int $line): string
|
private function getCodeSnippet(string $file, int $line): string
|
||||||
{
|
{
|
||||||
if (!file_exists($file)) {
|
if (! file_exists($file)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,17 +708,15 @@ HTML;
|
|||||||
$callEscaped = htmlspecialchars($call, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
$callEscaped = htmlspecialchars($call, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
$locationEscaped = htmlspecialchars($item->getShortFile() . ':' . $item->line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
$locationEscaped = htmlspecialchars($item->getShortFile() . ':' . $item->line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
$isVendor = $item->isVendorFrame();
|
$isVendor = $item->isVendorFrame();
|
||||||
$expanded = !$isVendor && $index < 3;
|
|
||||||
$codeSnippet = '';
|
$codeSnippet = '';
|
||||||
|
|
||||||
if (!$isVendor && $index < 3) {
|
if (! $isVendor && $index < 3) {
|
||||||
$codeSnippet = $this->getCodeSnippet($item->file, $item->line);
|
$codeSnippet = $this->getCodeSnippet($item->file, $item->line);
|
||||||
}
|
}
|
||||||
|
|
||||||
$frames[] = sprintf(
|
$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' : '',
|
$isVendor ? ' stack-frame--vendor' : '',
|
||||||
$expanded ? ' open' : '',
|
|
||||||
$index,
|
$index,
|
||||||
$callEscaped,
|
$callEscaped,
|
||||||
$locationEscaped,
|
$locationEscaped,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling\Reporter;
|
namespace App\Framework\ExceptionHandling\Reporter;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling\Reporter;
|
namespace App\Framework\ExceptionHandling\Reporter;
|
||||||
|
|
||||||
interface Reporter
|
interface Reporter
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final readonly class ReporterRegistry implements Reporter
|
|||||||
* @param Reporter[] $reporters Variadic list of reporter instances
|
* @param Reporter[] $reporters Variadic list of reporter instances
|
||||||
*/
|
*/
|
||||||
private array $reporters;
|
private array $reporters;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Reporter ...$reporters
|
Reporter ...$reporters
|
||||||
) {
|
) {
|
||||||
@@ -46,4 +47,3 @@ final readonly class ReporterRegistry implements Reporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class ErrorScope
|
|||||||
{
|
{
|
||||||
$id = $this->fiberId();
|
$id = $this->fiberId();
|
||||||
|
|
||||||
if (!isset($this->stack[$id])) {
|
if (! isset($this->stack[$id])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ final class ErrorScope
|
|||||||
array_pop($this->stack[$id]);
|
array_pop($this->stack[$id]);
|
||||||
} else {
|
} else {
|
||||||
// Exit all scopes until token depth
|
// 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]);
|
array_pop($this->stack[$id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,7 @@ final class ErrorScope
|
|||||||
$stack = $this->stack[$id] ?? [];
|
$stack = $this->stack[$id] ?? [];
|
||||||
|
|
||||||
$current = end($stack);
|
$current = end($stack);
|
||||||
|
|
||||||
return $current !== false ? $current : null;
|
return $current !== false ? $current : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +91,8 @@ final class ErrorScope
|
|||||||
public function hasScope(): bool
|
public function hasScope(): bool
|
||||||
{
|
{
|
||||||
$id = $this->fiberId();
|
$id = $this->fiberId();
|
||||||
return !empty($this->stack[$id]);
|
|
||||||
|
return ! empty($this->stack[$id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +101,7 @@ final class ErrorScope
|
|||||||
public function depth(): int
|
public function depth(): int
|
||||||
{
|
{
|
||||||
$id = $this->fiberId();
|
$id = $this->fiberId();
|
||||||
|
|
||||||
return count($this->stack[$id] ?? []);
|
return count($this->stack[$id] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ final class ErrorScope
|
|||||||
private function fiberId(): int
|
private function fiberId(): int
|
||||||
{
|
{
|
||||||
$fiber = Fiber::getCurrent();
|
$fiber = Fiber::getCurrent();
|
||||||
|
|
||||||
return $fiber ? spl_object_id($fiber) : 0;
|
return $fiber ? spl_object_id($fiber) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +133,7 @@ final class ErrorScope
|
|||||||
return [
|
return [
|
||||||
'active_fibers' => count($this->stack),
|
'active_fibers' => count($this->stack),
|
||||||
'total_scopes' => array_sum(array_map('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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ final readonly class ErrorScopeContext
|
|||||||
public ?string $jobId = null,
|
public ?string $jobId = null,
|
||||||
public ?string $commandName = null,
|
public ?string $commandName = null,
|
||||||
public array $tags = [],
|
public array $tags = [],
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create HTTP scope from request
|
* Create HTTP scope from request
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Framework\ExceptionHandling\Serialization;
|
|||||||
|
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
use App\Framework\Logging\ValueObjects\ExceptionContext as LoggingExceptionContext;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +35,7 @@ final readonly class ExceptionSerializer
|
|||||||
public function toJson(Throwable $exception, array $options = []): string
|
public function toJson(Throwable $exception, array $options = []): string
|
||||||
{
|
{
|
||||||
$data = $this->toArray($exception, $options);
|
$data = $this->toArray($exception, $options);
|
||||||
|
|
||||||
return json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
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
|
// Add domain data
|
||||||
if (!empty($context->data)) {
|
if (! empty($context->data)) {
|
||||||
$logData['domain_data'] = $context->data;
|
$logData['domain_data'] = $context->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add metadata
|
// Add metadata
|
||||||
if (!empty($context->metadata)) {
|
if (! empty($context->metadata)) {
|
||||||
$logData['metadata'] = $context->metadata;
|
$logData['metadata'] = $context->metadata;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +290,7 @@ final readonly class ExceptionSerializer
|
|||||||
$serialized[] = $arg;
|
$serialized[] = $arg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $serialized;
|
return $serialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,4 +333,3 @@ final readonly class ExceptionSerializer
|
|||||||
return (string) $value;
|
return (string) $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
@@ -9,13 +10,14 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ErrorKernel $errorKernel
|
private ErrorKernel $errorKernel
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
$last = error_get_last();
|
$last = error_get_last();
|
||||||
|
|
||||||
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
|
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
|||||||
$file = (string)($last['file'] ?? 'unknown');
|
$file = (string)($last['file'] ?? 'unknown');
|
||||||
$line = (int)($last['line'] ?? 0);
|
$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
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling;
|
namespace App\Framework\ExceptionHandling;
|
||||||
|
|
||||||
interface ShutdownHandlerInterface
|
interface ShutdownHandlerInterface
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling\Strategy;
|
namespace App\Framework\ExceptionHandling\Strategy;
|
||||||
@@ -11,13 +12,14 @@ final readonly class ErrorPolicyResolver
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ?Logger $logger = null
|
private ?Logger $logger = null
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
|
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
|
||||||
{
|
{
|
||||||
return match(true) {
|
return match(true) {
|
||||||
$environmentType->isProduction() => new StrictErrorPolicy(),
|
$environmentType->isProduction() => new StrictErrorPolicy(),
|
||||||
$environmentType->isDevelopment() => $this->logger !== null
|
$environmentType->isDevelopment() => $this->logger !== null
|
||||||
? new LenientPolicy($this->logger)
|
? new LenientPolicy($this->logger)
|
||||||
: new StrictErrorPolicy(),
|
: new StrictErrorPolicy(),
|
||||||
default => new SilentErrorPolicy(),
|
default => new SilentErrorPolicy(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling\Strategy;
|
namespace App\Framework\ExceptionHandling\Strategy;
|
||||||
@@ -19,19 +20,21 @@ final readonly class LenientPolicy implements ErrorHandlerStrategy
|
|||||||
|
|
||||||
public function handle(ErrorContext $context): ErrorDecision
|
public function handle(ErrorContext $context): ErrorDecision
|
||||||
{
|
{
|
||||||
if($context->isDeprecation()) {
|
if ($context->isDeprecation()) {
|
||||||
$this->logger->notice("[Deprecation] {$context->message}",
|
$this->logger->notice(
|
||||||
|
"[Deprecation] {$context->message}",
|
||||||
LogContext::withData(
|
LogContext::withData(
|
||||||
[
|
[
|
||||||
'file' => $context->file,
|
'file' => $context->file,
|
||||||
'line' => $context->line?->toInt()
|
'line' => $context->line?->toInt(),
|
||||||
]
|
]
|
||||||
));
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return ErrorDecision::HANDLED;
|
return ErrorDecision::HANDLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if($context->isFatal()) {
|
if ($context->isFatal()) {
|
||||||
throw new ErrorException(
|
throw new ErrorException(
|
||||||
$context->message,
|
$context->message,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling\Strategy;
|
namespace App\Framework\ExceptionHandling\Strategy;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Framework\ExceptionHandling\Strategy;
|
namespace App\Framework\ExceptionHandling\Strategy;
|
||||||
|
|||||||
418
src/Framework/ExceptionHandling/Templates/errors/error.view.php
Normal file
418
src/Framework/ExceptionHandling/Templates/errors/error.view.php
Normal 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>
|
||||||
@@ -52,6 +52,7 @@ final readonly class ExceptionMessageTranslator
|
|||||||
// Process template
|
// Process template
|
||||||
if (is_string($template)) {
|
if (is_string($template)) {
|
||||||
$message = $this->processTemplate($template, $exception, $context);
|
$message = $this->processTemplate($template, $exception, $context);
|
||||||
|
|
||||||
return UserFriendlyMessage::simple($message);
|
return UserFriendlyMessage::simple($message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,4 +98,3 @@ final readonly class ExceptionMessageTranslator
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,4 +49,3 @@ final readonly class UserFriendlyMessage
|
|||||||
return new self($message, helpText: $helpText);
|
return new self($message, helpText: $helpText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ final readonly class StackItem
|
|||||||
private static function sanitizeArgs(array $args): array
|
private static function sanitizeArgs(array $args): array
|
||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
fn($arg) => self::sanitizeValue($arg),
|
fn ($arg) => self::sanitizeValue($arg),
|
||||||
$args
|
$args
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,7 @@ final readonly class StackItem
|
|||||||
$reflection = new \ReflectionFunction($value);
|
$reflection = new \ReflectionFunction($value);
|
||||||
$file = $reflection->getFileName();
|
$file = $reflection->getFileName();
|
||||||
$line = $reflection->getStartLine();
|
$line = $reflection->getStartLine();
|
||||||
|
|
||||||
return sprintf('Closure(%s:%d)', basename($file), $line);
|
return sprintf('Closure(%s:%d)', basename($file), $line);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return 'Closure';
|
return 'Closure';
|
||||||
@@ -102,6 +103,7 @@ final readonly class StackItem
|
|||||||
if ($parentClass !== false) {
|
if ($parentClass !== false) {
|
||||||
return sprintf('Anonymous(%s)', $parentClass);
|
return sprintf('Anonymous(%s)', $parentClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Anonymous';
|
return 'Anonymous';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ final readonly class StackItem
|
|||||||
// Arrays rekursiv bereinigen
|
// Arrays rekursiv bereinigen
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
return array_map(
|
return array_map(
|
||||||
fn($item) => self::sanitizeValue($item),
|
fn ($item) => self::sanitizeValue($item),
|
||||||
$value
|
$value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,15 +147,17 @@ final readonly class StackItem
|
|||||||
// Entferne Namespace: Spalte am Backslash und nimm den letzten Teil
|
// Entferne Namespace: Spalte am Backslash und nimm den letzten Teil
|
||||||
$parts = explode('\\', $interfaceName);
|
$parts = explode('\\', $interfaceName);
|
||||||
$shortName = end($parts);
|
$shortName = end($parts);
|
||||||
|
|
||||||
return $shortName . ' (anonymous)';
|
return $shortName . ' (anonymous)';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Anonymous';
|
return 'Anonymous';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spalte am Backslash und nimm den letzten Teil (Klassenname ohne Namespace)
|
// Spalte am Backslash und nimm den letzten Teil (Klassenname ohne Namespace)
|
||||||
$parts = explode('\\', $normalizedClass);
|
$parts = explode('\\', $normalizedClass);
|
||||||
$shortName = end($parts);
|
$shortName = end($parts);
|
||||||
|
|
||||||
// Sicherstellen, dass wir wirklich nur den letzten Teil zurückgeben
|
// Sicherstellen, dass wir wirklich nur den letzten Teil zurückgeben
|
||||||
// (falls explode() nicht funktioniert hat, z.B. bei Forward-Slashes)
|
// (falls explode() nicht funktioniert hat, z.B. bei Forward-Slashes)
|
||||||
if ($shortName === $normalizedClass && str_contains($normalizedClass, '/')) {
|
if ($shortName === $normalizedClass && str_contains($normalizedClass, '/')) {
|
||||||
@@ -161,7 +165,7 @@ final readonly class StackItem
|
|||||||
$parts = explode('/', $normalizedClass);
|
$parts = explode('/', $normalizedClass);
|
||||||
$shortName = end($parts);
|
$shortName = end($parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $shortName;
|
return $shortName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +230,7 @@ final readonly class StackItem
|
|||||||
foreach ($this->args as $index => $arg) {
|
foreach ($this->args as $index => $arg) {
|
||||||
if ($index >= $maxArgs) {
|
if ($index >= $maxArgs) {
|
||||||
$formatted[] = '…';
|
$formatted[] = '…';
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +240,7 @@ final readonly class StackItem
|
|||||||
|
|
||||||
if ($length > $maxTotalLength) {
|
if ($length > $maxTotalLength) {
|
||||||
$formatted[] = '…';
|
$formatted[] = '…';
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,6 +277,7 @@ final readonly class StackItem
|
|||||||
if (class_exists($normalizedValue) || interface_exists($normalizedValue) || enum_exists($normalizedValue)) {
|
if (class_exists($normalizedValue) || interface_exists($normalizedValue) || enum_exists($normalizedValue)) {
|
||||||
$parts = explode('\\', $normalizedValue);
|
$parts = explode('\\', $normalizedValue);
|
||||||
$shortName = end($parts);
|
$shortName = end($parts);
|
||||||
|
|
||||||
return sprintf("'%s'", $shortName);
|
return sprintf("'%s'", $shortName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +289,7 @@ final readonly class StackItem
|
|||||||
// Nur wenn es mehrere Teile gibt (Namespace vorhanden)
|
// Nur wenn es mehrere Teile gibt (Namespace vorhanden)
|
||||||
if (count($parts) > 1) {
|
if (count($parts) > 1) {
|
||||||
$shortName = end($parts);
|
$shortName = end($parts);
|
||||||
|
|
||||||
return sprintf("'%s'", $shortName);
|
return sprintf("'%s'", $shortName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +298,7 @@ final readonly class StackItem
|
|||||||
if (preg_match('/^Closure\(([^:]+):(\d+)\)$/', $value, $matches)) {
|
if (preg_match('/^Closure\(([^:]+):(\d+)\)$/', $value, $matches)) {
|
||||||
$file = basename($matches[1]);
|
$file = basename($matches[1]);
|
||||||
$line = $matches[2];
|
$line = $matches[2];
|
||||||
|
|
||||||
return sprintf("Closure(%s:%s)", $file, $line);
|
return sprintf("Closure(%s:%s)", $file, $line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,8 +328,10 @@ final readonly class StackItem
|
|||||||
$interfaceName = $matches[1];
|
$interfaceName = $matches[1];
|
||||||
$interfaceParts = explode('\\', $interfaceName);
|
$interfaceParts = explode('\\', $interfaceName);
|
||||||
$shortInterface = end($interfaceParts);
|
$shortInterface = end($interfaceParts);
|
||||||
|
|
||||||
return $shortInterface . ' (anonymous)';
|
return $shortInterface . ' (anonymous)';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Anonymous';
|
return 'Anonymous';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,25 +352,26 @@ final readonly class StackItem
|
|||||||
$closureInfo = $matches[1];
|
$closureInfo = $matches[1];
|
||||||
// Normalisiere Forward-Slashes zu Backslashes
|
// Normalisiere Forward-Slashes zu Backslashes
|
||||||
$closureInfo = str_replace('/', '\\', $closureInfo);
|
$closureInfo = str_replace('/', '\\', $closureInfo);
|
||||||
|
|
||||||
// Parse: App\Framework\Router\RouteDispatcher::executeController():77
|
// Parse: App\Framework\Router\RouteDispatcher::executeController():77
|
||||||
if (preg_match('/^([^:]+)::([^(]+)\(\):(\d+)$/', $closureInfo, $closureMatches)) {
|
if (preg_match('/^([^:]+)::([^(]+)\(\):(\d+)$/', $closureInfo, $closureMatches)) {
|
||||||
$fullClass = $closureMatches[1];
|
$fullClass = $closureMatches[1];
|
||||||
$method = $closureMatches[2];
|
$method = $closureMatches[2];
|
||||||
$line = $closureMatches[3];
|
$line = $closureMatches[3];
|
||||||
|
|
||||||
// Entferne Namespace
|
// Entferne Namespace
|
||||||
$classParts = explode('\\', $fullClass);
|
$classParts = explode('\\', $fullClass);
|
||||||
$shortClass = end($classParts);
|
$shortClass = end($classParts);
|
||||||
|
|
||||||
return sprintf('{closure:%s::%s():%s}', $shortClass, $method, $line);
|
return sprintf('{closure:%s::%s():%s}', $shortClass, $method, $line);
|
||||||
}
|
}
|
||||||
// Fallback: einfach Namespaces entfernen
|
// Fallback: einfach Namespaces entfernen
|
||||||
$closureInfo = preg_replace_callback(
|
$closureInfo = preg_replace_callback(
|
||||||
'/([A-Z][a-zA-Z0-9_\\\\]*)/',
|
'/([A-Z][a-zA-Z0-9_\\\\]*)/',
|
||||||
fn($m) => $this->removeNamespaceFromClass($m[0]),
|
fn ($m) => $this->removeNamespaceFromClass($m[0]),
|
||||||
$closureInfo
|
$closureInfo
|
||||||
);
|
);
|
||||||
|
|
||||||
return sprintf('{closure:%s}', $closureInfo);
|
return sprintf('{closure:%s}', $closureInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +386,7 @@ final readonly class StackItem
|
|||||||
// Normalisiere Forward-Slashes zu Backslashes
|
// Normalisiere Forward-Slashes zu Backslashes
|
||||||
$normalized = str_replace('/', '\\', $classString);
|
$normalized = str_replace('/', '\\', $classString);
|
||||||
$parts = explode('\\', $normalized);
|
$parts = explode('\\', $normalized);
|
||||||
|
|
||||||
return end($parts);
|
return end($parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +416,7 @@ final readonly class StackItem
|
|||||||
$methodName = $this->formatFunctionName($this->function);
|
$methodName = $this->formatFunctionName($this->function);
|
||||||
$separator = $this->type === '::' ? '::' : '->';
|
$separator = $this->type === '::' ? '::' : '->';
|
||||||
$paramsStr = $params !== '' ? $params : '';
|
$paramsStr = $params !== '' ? $params : '';
|
||||||
|
|
||||||
return sprintf('%s%s%s(%s) in %s', $className, $separator, $methodName, $paramsStr, $location);
|
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) {
|
if ($this->function !== null) {
|
||||||
$methodName = $this->formatFunctionName($this->function);
|
$methodName = $this->formatFunctionName($this->function);
|
||||||
$paramsStr = $params !== '' ? $params : '';
|
$paramsStr = $params !== '' ? $params : '';
|
||||||
|
|
||||||
return sprintf('%s(%s) in %s', $methodName, $paramsStr, $location);
|
return sprintf('%s(%s) in %s', $methodName, $paramsStr, $location);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +458,7 @@ final readonly class StackItem
|
|||||||
$data['type'] = $this->type;
|
$data['type'] = $this->type;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($this->args)) {
|
if (! empty($this->args)) {
|
||||||
$data['args'] = $this->serializeArgs();
|
$data['args'] = $this->serializeArgs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +473,7 @@ final readonly class StackItem
|
|||||||
private function serializeArgs(): array
|
private function serializeArgs(): array
|
||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
fn($arg) => $this->formatValueForOutput($arg),
|
fn ($arg) => $this->formatValueForOutput($arg),
|
||||||
$this->args
|
$this->args
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -484,6 +499,7 @@ final readonly class StackItem
|
|||||||
}
|
}
|
||||||
|
|
||||||
$projectRoot = dirname(__DIR__, 4);
|
$projectRoot = dirname(__DIR__, 4);
|
||||||
|
|
||||||
return $projectRoot;
|
return $projectRoot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Framework\ExceptionHandling\ValueObjects;
|
namespace App\Framework\ExceptionHandling\ValueObjects;
|
||||||
|
|
||||||
use IteratorAggregate;
|
|
||||||
use Countable;
|
use Countable;
|
||||||
|
use IteratorAggregate;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +48,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable
|
|||||||
array_values(
|
array_values(
|
||||||
array_filter(
|
array_filter(
|
||||||
$this->items,
|
$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);
|
$frames = array_slice($this->items, 0, $maxFrames);
|
||||||
$formatted = array_map(
|
$formatted = array_map(
|
||||||
fn(StackItem $item) => $item->formatShort(),
|
fn (StackItem $item) => $item->formatShort(),
|
||||||
$frames
|
$frames
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable
|
|||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
fn(StackItem $item) => $item->toArray(),
|
fn (StackItem $item) => $item->toArray(),
|
||||||
$this->items
|
$this->items
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,4 +148,3 @@ final readonly class StackTrace implements IteratorAggregate, Countable
|
|||||||
return count($this->items);
|
return count($this->items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
160
tests/Unit/Framework/Core/ValueObjects/FrameworkModuleTest.php
Normal file
160
tests/Unit/Framework/Core/ValueObjects/FrameworkModuleTest.php
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,13 +4,187 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Integration;
|
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\ExceptionContextData;
|
||||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||||
|
use App\Framework\ExceptionHandling\ErrorHandlingConfig;
|
||||||
use App\Framework\ExceptionHandling\ErrorKernel;
|
use App\Framework\ExceptionHandling\ErrorKernel;
|
||||||
|
use App\Framework\ExceptionHandling\ErrorRendererFactory;
|
||||||
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
|
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
|
||||||
use App\Framework\Http\Status;
|
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;
|
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
|
* 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 () {
|
it('creates JSON API error response without context', function () {
|
||||||
$errorKernel = new ErrorKernel();
|
$errorKernel = createTestErrorKernel();
|
||||||
$exception = new RuntimeException('Test API error', 500);
|
$exception = new RuntimeException('Test API error', 500);
|
||||||
|
|
||||||
$response = $errorKernel->createHttpResponse($exception, null, isDebugMode: false);
|
$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 () {
|
it('creates JSON API error response with debug mode', function () {
|
||||||
$errorKernel = new ErrorKernel();
|
$errorKernel = createTestErrorKernel();
|
||||||
$exception = new RuntimeException('Database connection failed', 500);
|
$exception = new RuntimeException('Database connection failed', 500);
|
||||||
|
|
||||||
$response = $errorKernel->createHttpResponse($exception, null, isDebugMode: true);
|
$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 () {
|
it('creates JSON API error response with WeakMap context', function () {
|
||||||
$errorKernel = new ErrorKernel();
|
$errorKernel = createTestErrorKernel();
|
||||||
$contextProvider = new ExceptionContextProvider();
|
$contextProvider = new ExceptionContextProvider();
|
||||||
$exception = new RuntimeException('User operation failed', 500);
|
$exception = new RuntimeException('User operation failed', 500);
|
||||||
|
|
||||||
@@ -100,10 +274,10 @@ describe('ResponseErrorRenderer', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('detects API requests correctly', function () {
|
it('detects API requests correctly', function () {
|
||||||
$renderer = new ResponseErrorRenderer(isDebugMode: false);
|
$renderer = createTestResponseErrorRenderer(isDebugMode: false);
|
||||||
$exception = new RuntimeException('Test error');
|
$exception = new RuntimeException('Test error');
|
||||||
|
|
||||||
$response = $renderer->createResponse($exception, null);
|
$response = $renderer->render($exception, null);
|
||||||
|
|
||||||
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
|
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
|
||||||
});
|
});
|
||||||
@@ -113,10 +287,10 @@ describe('ResponseErrorRenderer', function () {
|
|||||||
$_SERVER['HTTP_ACCEPT'] = 'text/html';
|
$_SERVER['HTTP_ACCEPT'] = 'text/html';
|
||||||
$_SERVER['REQUEST_URI'] = '/web/page';
|
$_SERVER['REQUEST_URI'] = '/web/page';
|
||||||
|
|
||||||
$renderer = new ResponseErrorRenderer(isDebugMode: false);
|
$renderer = createTestResponseErrorRenderer(isDebugMode: false);
|
||||||
$exception = new RuntimeException('Page error');
|
$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->headers->getFirst('Content-Type'))->toBe('text/html; charset=utf-8');
|
||||||
expect($response->body)->toContain('<!DOCTYPE html>');
|
expect($response->body)->toContain('<!DOCTYPE html>');
|
||||||
@@ -127,7 +301,7 @@ describe('ResponseErrorRenderer', function () {
|
|||||||
$_SERVER['HTTP_ACCEPT'] = 'text/html';
|
$_SERVER['HTTP_ACCEPT'] = 'text/html';
|
||||||
$_SERVER['REQUEST_URI'] = '/web/page';
|
$_SERVER['REQUEST_URI'] = '/web/page';
|
||||||
|
|
||||||
$renderer = new ResponseErrorRenderer(isDebugMode: true);
|
$renderer = createTestResponseErrorRenderer(isDebugMode: true);
|
||||||
$contextProvider = new ExceptionContextProvider();
|
$contextProvider = new ExceptionContextProvider();
|
||||||
$exception = new RuntimeException('Debug test error');
|
$exception = new RuntimeException('Debug test error');
|
||||||
|
|
||||||
@@ -139,7 +313,7 @@ describe('ResponseErrorRenderer', function () {
|
|||||||
);
|
);
|
||||||
$contextProvider->attach($exception, $contextData);
|
$contextProvider->attach($exception, $contextData);
|
||||||
|
|
||||||
$response = $renderer->createResponse($exception, $contextProvider);
|
$response = $renderer->render($exception, $contextProvider);
|
||||||
|
|
||||||
expect($response->body)->toContain('Debug Information');
|
expect($response->body)->toContain('Debug Information');
|
||||||
expect($response->body)->toContain('page.render');
|
expect($response->body)->toContain('page.render');
|
||||||
@@ -148,21 +322,21 @@ describe('ResponseErrorRenderer', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maps exception types to HTTP status codes correctly', function () {
|
it('maps exception types to HTTP status codes correctly', function () {
|
||||||
$renderer = new ResponseErrorRenderer();
|
$renderer = createTestResponseErrorRenderer();
|
||||||
|
|
||||||
// InvalidArgumentException → 400
|
// InvalidArgumentException → 400
|
||||||
$exception = new \InvalidArgumentException('Invalid input');
|
$exception = new \InvalidArgumentException('Invalid input');
|
||||||
$response = $renderer->createResponse($exception, null);
|
$response = $renderer->render($exception, null);
|
||||||
expect($response->status)->toBe(Status::BAD_REQUEST);
|
expect($response->status)->toBe(Status::BAD_REQUEST);
|
||||||
|
|
||||||
// RuntimeException → 500
|
// RuntimeException → 500
|
||||||
$exception = new RuntimeException('Runtime error');
|
$exception = new RuntimeException('Runtime error');
|
||||||
$response = $renderer->createResponse($exception, null);
|
$response = $renderer->render($exception, null);
|
||||||
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
|
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
|
||||||
|
|
||||||
// Custom code in valid range
|
// Custom code in valid range
|
||||||
$exception = new RuntimeException('Not found', 404);
|
$exception = new RuntimeException('Not found', 404);
|
||||||
$response = $renderer->createResponse($exception, null);
|
$response = $renderer->render($exception, null);
|
||||||
expect($response->status)->toBe(Status::NOT_FOUND);
|
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 () {
|
it('demonstrates full exception handling flow with context enrichment', function () {
|
||||||
// Setup
|
// Setup
|
||||||
$errorKernel = new ErrorKernel();
|
$errorKernel = createTestErrorKernel();
|
||||||
$contextProvider = new ExceptionContextProvider();
|
$contextProvider = new ExceptionContextProvider();
|
||||||
|
|
||||||
// 1. Exception occurs in service layer
|
// 1. Exception occurs in service layer
|
||||||
|
|||||||
Reference in New Issue
Block a user