feat: add comprehensive framework features and deployment improvements

Major additions:
- Storage abstraction layer with filesystem and in-memory implementations
- Gitea API integration with MCP tools for repository management
- Console dialog mode with interactive command execution
- WireGuard VPN DNS fix implementation and documentation
- HTTP client streaming response support
- Router generic result type
- Parameter type validator for framework core

Framework enhancements:
- Console command registry improvements
- Console dialog components
- Method signature analyzer updates
- Route mapper refinements
- MCP server and tool mapper updates
- Queue job chain and dependency commands
- Discovery tokenizer improvements

Infrastructure:
- Deployment architecture documentation
- Ansible playbook updates for WireGuard client regeneration
- Production environment configuration updates
- Docker Compose local configuration updates
- Remove obsolete docker-compose.yml (replaced by environment-specific configs)

Documentation:
- PERMISSIONS.md for access control guidelines
- WireGuard DNS fix implementation details
- Console dialog mode usage guide
- Deployment architecture overview

Testing:
- Multi-purpose attribute tests
- Gitea Actions integration tests (typed and untyped)
This commit is contained in:
2025-11-04 20:39:48 +01:00
parent 700fe8118b
commit 3ed2685e74
80 changed files with 9891 additions and 850 deletions

View File

@@ -228,10 +228,24 @@ final readonly class CommandRegistry
private function normalizeCommandResult($result): ExitCode
{
// Handle ActionResult (including ConsoleResult)
if ($result instanceof \App\Framework\Router\ActionResult) {
// If it's already a ConsoleResult, render it and return exit code
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
// Rendering will be handled by ConsoleApplication::processCommandResult
return $result->exitCode;
}
// Convert other ActionResult types to ConsoleResult
return $this->convertActionResultToConsoleResult($result)->exitCode;
}
// Legacy ExitCode pattern
if ($result instanceof ExitCode) {
return $result;
}
// Legacy int pattern (for backwards compatibility)
if (is_int($result)) {
try {
return ExitCode::from($result);
@@ -240,6 +254,7 @@ final readonly class CommandRegistry
}
}
// Legacy bool pattern
if (is_bool($result)) {
return $result ? ExitCode::SUCCESS : ExitCode::GENERAL_ERROR;
}
@@ -247,6 +262,42 @@ final readonly class CommandRegistry
return ExitCode::SUCCESS;
}
/**
* Convert ActionResult to ConsoleResult
*/
private function convertActionResultToConsoleResult(\App\Framework\Router\ActionResult $result): \App\Framework\Console\Result\ConsoleResult
{
// GenericResult: Use built-in conversion
if ($result instanceof \App\Framework\Router\GenericResult) {
return $result->toConsoleResult();
}
// Convert JsonResult to TextResult
if ($result instanceof \App\Framework\Router\Result\JsonResult) {
$json = json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return \App\Framework\Console\Result\TextResult::info($json);
}
// Convert ToolResult to TextResult
if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
$message = $result->success
? 'Operation completed successfully'
: 'Operation failed: ' . ($result->error ?? 'Unknown error');
$data = $result->data !== null ? json_encode($result->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '';
if ($data) {
$message .= "\n" . $data;
}
return $result->success
? \App\Framework\Console\Result\TextResult::success($message)
: \App\Framework\Console\Result\TextResult::error($message);
}
// Default: convert to info text
return \App\Framework\Console\Result\TextResult::info('Command executed successfully');
}
/**
* Execute command with automatic parameter resolution
*/
@@ -278,6 +329,18 @@ final readonly class CommandRegistry
$result = $method->invokeArgs($instance, $resolvedParams);
// Handle ActionResult - if it's a ConsoleResult, render it
if ($result instanceof \App\Framework\Router\ActionResult) {
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
$result->render($progressAwareOutput);
return $result->exitCode;
}
// Convert other ActionResult types
$consoleResult = $this->convertActionResultToConsoleResult($result);
$consoleResult->render($progressAwareOutput);
return $consoleResult->exitCode;
}
return $this->normalizeCommandResult($result);
};

View File

@@ -23,7 +23,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
*/
final readonly class ConsoleDialog
{
private bool $readlineAvailable = false;
private bool $readlineAvailable;
private CommandSuggestionEngine $suggestionEngine;

View File

@@ -4,12 +4,22 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Attributes\Route;
use App\Framework\Core\AttributeMapper;
use App\Framework\Core\ParameterTypeValidator;
use App\Framework\Mcp\McpTool;
use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod;
final readonly class ConsoleCommandMapper implements AttributeMapper
{
private ParameterTypeValidator $typeValidator;
public function __construct()
{
$this->typeValidator = new ParameterTypeValidator();
}
public function getAttributeClass(): string
{
return ConsoleCommand::class;
@@ -21,6 +31,26 @@ final readonly class ConsoleCommandMapper implements AttributeMapper
return null; // ConsoleCommand can only be applied to methods
}
// Check if method has multiple attributes (multi-purpose)
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
// If multi-purpose, validate that all parameters are builtin types
if ($hasMultipleAttributes) {
$parameters = $reflectionTarget->getParameters()->toArray();
$reflectionParameters = [];
foreach ($parameters as $param) {
$reflectionParameters[] = $param->getType();
}
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
// Skip this attribute if parameters are not all builtin
return null;
}
}
// Get other attributes for metadata
$otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget);
return [
'attribute_data' => [
'name' => $attributeInstance->name,
@@ -28,6 +58,53 @@ final readonly class ConsoleCommandMapper implements AttributeMapper
],
'class' => $reflectionTarget->getDeclaringClass(),
'method' => $reflectionTarget->getName(),
'multi_purpose' => $hasMultipleAttributes,
'other_attributes' => $otherAttributes,
];
}
/**
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
*/
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
{
$attributes = $method->getAttributes();
$purposeAttributeCount = 0;
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
Route::class,
], true)) {
$purposeAttributeCount++;
}
}
return $purposeAttributeCount > 1;
}
/**
* Get other purpose attributes on the same method
*
* @return array<string>
*/
private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array
{
$attributes = $method->getAttributes();
$otherAttributes = [];
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
Route::class,
], true)) {
$otherAttributes[] = $attributeName;
}
}
return $otherAttributes;
}
}

View File

@@ -330,7 +330,15 @@ final readonly class MethodSignatureAnalyzer
$returnType = $method->getReturnType();
if ($returnType instanceof ReflectionNamedType) {
$returnTypeName = $returnType->getName();
if (! in_array($returnTypeName, ['int', ExitCode::class], true)) {
// Accept: int, ExitCode, ActionResult, or array
$validReturnTypes = [
'int',
ExitCode::class,
'App\Framework\MagicLinks\Actions\ActionResult',
'array'
];
if (! in_array($returnTypeName, $validReturnTypes, true)) {
return false;
}
}

View File

@@ -2,6 +2,31 @@
Dieses Modul bietet eine flexible und benutzerfreundliche Konsolen-Schnittstelle für Ihre PHP-Anwendung. Es ermöglicht die Erstellung von CLI-Befehlen mit einfacher Eingabe- und Ausgabehandlung.
## Interaktive Modi
Das Console-Modul bietet zwei interaktive Modi:
### TUI (Text User Interface) - Standard
Grafische Terminal-UI mit Maus-Unterstützung und Navigation:
```bash
php console.php # Startet TUI (Standard)
php console.php --interactive # Startet TUI explizit
php console.php --tui # Startet TUI explizit
```
### Dialog-Modus - AI-Assistent-ähnlich
Einfache Prompt-Eingabe mit Tab-Completion und History:
```bash
php console.php --dialog # Startet Dialog-Modus
php console.php --chat # Startet Dialog-Modus (Alias)
```
**Weitere Informationen**: Siehe [Console Dialog Mode Dokumentation](../../docs/console-dialog-mode.md)
## Hauptkomponenten
### ConsoleApplication

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Router\ActionResult;
/**
* Console Result Interface
@@ -18,7 +19,7 @@ use App\Framework\Console\ExitCode;
* - Rendering logic
* - Metadata for testing/introspection
*/
interface ConsoleResult
interface ConsoleResult extends ActionResult
{
/**
* Exit code for this result

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionType;
use ReflectionUnionType;
/**
* Validates parameter types to check if they are builtin types
*/
final readonly class ParameterTypeValidator
{
/**
* Check if a parameter type is a builtin type
*/
public function isBuiltinType(?\ReflectionType $type): bool
{
if ($type === null) {
return false; // No type = mixed, not allowed
}
if ($type instanceof ReflectionNamedType) {
return $this->isBuiltinTypeName($type->getName());
}
if ($type instanceof ReflectionUnionType) {
return $this->isBuiltinUnionType($type);
}
if ($type instanceof ReflectionIntersectionType) {
return false; // Intersection types are not builtin
}
return false;
}
/**
* Check if all parameters of a method are builtin types
*
* @param array<\ReflectionType|null> $types
*/
public function hasOnlyBuiltinParameters(array $types): bool
{
foreach ($types as $type) {
if (! $this->isBuiltinType($type)) {
return false;
}
}
return true;
}
/**
* Check if a type name is a builtin type
*/
private function isBuiltinTypeName(string $typeName): bool
{
$builtinTypes = [
'string',
'int',
'float',
'bool',
'array',
'null',
'mixed',
];
return in_array($typeName, $builtinTypes, true);
}
/**
* Check if a union type only contains builtin types
*/
private function isBuiltinUnionType(ReflectionUnionType $type): bool
{
foreach ($type->getTypes() as $unionType) {
if (! $unionType instanceof ReflectionNamedType) {
return false;
}
$typeName = $unionType->getName();
// Allow null in unions (e.g., ?string, string|null)
if ($typeName === 'null') {
continue;
}
if (! $this->isBuiltinTypeName($typeName)) {
return false;
}
}
return true;
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Attributes\Route;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Mcp\McpTool;
use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod;
use App\Framework\Router\ValueObjects\MethodParameter;
@@ -12,6 +14,13 @@ use App\Framework\Router\ValueObjects\ParameterCollection;
final readonly class RouteMapper implements AttributeMapper
{
private ParameterTypeValidator $typeValidator;
public function __construct()
{
$this->typeValidator = new ParameterTypeValidator();
}
public function getAttributeClass(): string
{
return Route::class;
@@ -28,11 +37,44 @@ final readonly class RouteMapper implements AttributeMapper
return null;
}
// Check if method has multiple attributes (multi-purpose)
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
// If multi-purpose, validate that all parameters are builtin types
if ($hasMultipleAttributes) {
$parameters = $reflectionTarget->getParameters()->toArray();
$reflectionParameters = [];
foreach ($parameters as $param) {
$reflectionParameters[] = $param->getType();
}
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
// Skip this attribute if parameters are not all builtin
return null;
}
}
// Collect all non-Route attributes on the method
$attributes = [];
$otherPurposeAttributes = [];
$hasWebhookEndpoint = false;
foreach ($reflectionTarget->getAttributes() as $attribute) {
if ($attribute->getName() !== Route::class) {
$attributes[] = $attribute->getName();
$attributeName = $attribute->getName();
if ($attributeName !== Route::class) {
$attributes[] = $attributeName;
// Track purpose attributes
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
], true)) {
$otherPurposeAttributes[] = $attributeName;
}
// Track WebhookEndpoint attribute
if ($attributeName === \App\Framework\Webhook\Attributes\WebhookEndpoint::class) {
$hasWebhookEndpoint = true;
}
}
}
@@ -52,6 +94,31 @@ final readonly class RouteMapper implements AttributeMapper
'parameters' => $parameterCollection->toLegacyArray(), // Backward compatibility
'parameter_collection' => $parameterCollection, // New type-safe collection
'attributes' => $attributes,
'multi_purpose' => $hasMultipleAttributes,
'other_attributes' => $otherPurposeAttributes,
'has_webhook_endpoint' => $hasWebhookEndpoint,
];
}
/**
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
*/
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
{
$attributes = $method->getAttributes();
$purposeAttributeCount = 0;
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
Route::class,
], true)) {
$purposeAttributeCount++;
}
}
return $purposeAttributeCount > 1;
}
}

View File

@@ -284,6 +284,39 @@ final readonly class ClassName implements Stringable
private function isValidClassName(string $className): bool
{
// Basic validation: should contain only alphanumeric, underscore, and backslash
return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) === 1;
if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $className) !== 1) {
return false;
}
// Extract the short name (last part after backslash)
$lastBackslash = strrpos($className, '\\');
$shortName = $lastBackslash === false ? $className : substr($className, $lastBackslash + 1);
// Reject known invalid class names (PHP keywords and common false positives)
$invalidNames = [
'implementation', 'interface', 'extends', 'implements',
'class', 'trait', 'enum', 'namespace', 'use', 'as',
'public', 'private', 'protected', 'static', 'final', 'abstract',
'function', 'return', 'if', 'else', 'foreach', 'while', 'for',
'true', 'false', 'null', 'void', 'mixed', 'array', 'object',
'string', 'int', 'float', 'bool', 'resource', 'callable', 'iterable'
];
if (in_array(strtolower($shortName), $invalidNames, true)) {
return false;
}
// Reject camelCase names (starting with lowercase) - class names should be PascalCase
// camelCase names are almost always methods, functions, or variables, not classes
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '_') && ! str_contains($shortName, '\\')) {
// This is camelCase - reject it as a class name
// Only allow if it's a known valid exception (very rare in PHP)
$validCamelCaseExceptions = [];
if (! in_array($shortName, $validCamelCaseExceptions, true)) {
return false;
}
}
return true;
}
}

View File

@@ -12,7 +12,6 @@ use App\Framework\Database\Driver\Optimization\SQLiteOptimizer;
use App\Framework\Database\Profiling\ProfilingDashboard;
use App\Framework\Database\Profiling\QueryProfiler;
use App\Framework\Database\Profiling\SlowQueryDetector;
use App\Framework\Http\Controller;
use App\Framework\Http\Response\JsonResponse;
use App\Framework\Http\Response\Response;
use App\Framework\Http\Response\ViewResponse;
@@ -21,7 +20,7 @@ use App\Framework\View\ViewRenderer;
/**
* Controller for the database performance dashboard
*/
final readonly class DatabaseDashboardController implements Controller
final readonly class DatabaseDashboardController
{
public function __construct(
private DatabaseManager $databaseManager,

View File

@@ -7,7 +7,6 @@ namespace App\Framework\Database\Monitoring\Dashboard;
use App\Framework\Attributes\Route;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Monitoring\Health\DatabaseHealthChecker;
use App\Framework\Http\Controller;
use App\Framework\Http\Response\JsonResponse;
use App\Framework\Http\Response\Response;
use App\Framework\Http\Response\ViewResponse;
@@ -16,7 +15,7 @@ use App\Framework\View\ViewRenderer;
/**
* Controller for the database health dashboard
*/
final readonly class DatabaseHealthController implements Controller
final readonly class DatabaseHealthController
{
public function __construct(
private DatabaseManager $databaseManager,

View File

@@ -6,7 +6,6 @@ namespace App\Framework\Database\Monitoring\Dashboard;
use App\Framework\Attributes\Route;
use App\Framework\Database\Monitoring\History\QueryHistoryLogger;
use App\Framework\Http\Controller;
use App\Framework\Http\Response\JsonResponse;
use App\Framework\Http\Response\Response;
use App\Framework\Http\Response\ViewResponse;
@@ -15,7 +14,7 @@ use App\Framework\View\ViewRenderer;
/**
* Controller for displaying historical query performance data
*/
final readonly class QueryHistoryController implements Controller
final readonly class QueryHistoryController
{
public function __construct(
private QueryHistoryLogger $historyLogger,

View File

@@ -38,6 +38,7 @@ final readonly class ClassExtractor
}
$classes = $this->tokenizer->extractClasses($content);
$fileNamespace = $this->extractFileNamespace($content);
$validClassNames = [];
foreach ($classes as $class) {
@@ -52,6 +53,22 @@ final readonly class ClassExtractor
continue;
}
// CRITICAL: Validate that the extracted class belongs to this file's namespace
// Classes should only be extracted from files where they are declared,
// not from files where they are only used (via use statements)
if (! $this->belongsToFileNamespace($fqn, $fileNamespace, $class['namespace'] ?? null)) {
continue;
}
// ADDITIONAL SAFETY: Verify that the class is actually declared in this file
// Search for "class <name>" or "interface <name>" etc. in the file content
// This catches cases where the tokenizer might extract wrong names
$shortClassName = $class['name'] ?? null;
if ($shortClassName !== null && ! $this->isClassDeclaredInFile($content, $shortClassName, $class['type'] ?? 'class')) {
// Class name found but not actually declared in this file - skip it
continue;
}
try {
$className = ClassName::create($fqn);
$validClassNames[] = $className;
@@ -96,30 +113,107 @@ final readonly class ClassExtractor
// Single lowercase word is suspicious - likely a property/method name
// But we'll be conservative and only reject known problematic names
$knownInvalid = ['state', 'container', 'get', 'set', 'map', 'compile', 'install', 'shouldretry', 'additionaldata'];
$knownInvalid = [
'state', 'container', 'get', 'set', 'map', 'compile', 'install',
'shouldretry', 'additionaldata',
// PHP keywords and common false positives from class extraction
'implementation', 'interface', 'extends', 'implements'
];
if (in_array(strtolower($shortName), $knownInvalid, true)) {
return false;
}
}
// camelCase starting with lowercase (likely method names)
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\')) {
$methodPrefixes = ['get', 'set', 'is', 'has', 'should', 'can', 'will', 'do', 'add', 'remove', 'update', 'delete'];
$lowercase = strtolower($shortName);
foreach ($methodPrefixes as $prefix) {
if (str_starts_with($lowercase, $prefix) && strlen($shortName) > strlen($prefix)) {
// Check if it's in our known invalid list
$knownInvalid = ['shouldretry', 'additionaldata'];
if (in_array($lowercase, $knownInvalid, true)) {
return false;
}
}
// camelCase starting with lowercase (likely method names) - STRICT REJECTION
// PHP class names should be PascalCase (start with uppercase)
// camelCase names are almost always methods, functions, or variables
if (preg_match('/^[a-z][a-zA-Z0-9]*$/', $shortName) && ! str_contains($shortName, '\\') && ! str_contains($shortName, '_')) {
// This is camelCase - reject it as a class name
// Only allow if it's a known valid exception (very rare)
$validCamelCaseExceptions = [];
if (! in_array($shortName, $validCamelCaseExceptions, true)) {
return false;
}
}
return true;
}
/**
* Extract namespace from file content
*/
private function extractFileNamespace(string $content): ?string
{
// Extract namespace from file using regex
if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) {
return trim($matches[1]);
}
return null;
}
/**
* Validate that extracted class belongs to the file's namespace
* This prevents extracting classes that are only used (via use statements) but not declared
*/
private function belongsToFileNamespace(string $fqn, ?string $fileNamespace, ?string $extractedNamespace): bool
{
// If file has no namespace (global namespace), only classes without namespace should be extracted
if ($fileNamespace === null || $fileNamespace === '') {
// Check if FQN has no namespace (global namespace) - no backslash or only one at start
return ! str_contains($fqn, '\\') || (str_starts_with($fqn, '\\') && substr_count($fqn, '\\') === 1);
}
// Extract namespace from FQN
$lastBackslash = strrpos($fqn, '\\');
if ($lastBackslash === false) {
// Class name without namespace - in a namespaced file, this shouldn't happen
// But if it does, it's invalid (classes in namespaced files should have namespace)
return false;
}
$fqnNamespace = substr($fqn, 0, $lastBackslash);
// Primary validation: Use the extracted namespace from tokenizer if available
// This is the most reliable source as it comes directly from the namespace declaration
// CRITICAL: Must match EXACTLY, not just start with
if ($extractedNamespace !== null && $extractedNamespace !== '') {
// Exact match required - no sub-namespaces allowed
if ($extractedNamespace !== $fileNamespace) {
return false;
}
// Also validate that FQN namespace matches
return $fqnNamespace === $fileNamespace;
}
// Fallback: Check if FQN namespace matches file namespace EXACTLY
// This ensures the full qualified name matches the file's namespace
// No partial matches - must be identical
return $fqnNamespace === $fileNamespace;
}
/**
* Check if a class is actually declared in the file content
* This prevents extracting classes that are only used (via use statements) but not declared
*/
private function isClassDeclaredInFile(string $content, string $className, string $type): bool
{
// Build pattern to match class/interface/trait/enum declarations
$keyword = match($type) {
'class' => 'class',
'interface' => 'interface',
'trait' => 'trait',
'enum' => 'enum',
default => 'class'
};
// Match: optional modifiers (final, abstract, readonly) + keyword + whitespace + class name
// Must be followed by whitespace, {, or extends/implements
$pattern = '/\b(?:final\s+|abstract\s+|readonly\s+)*' . preg_quote($keyword, '/') . '\s+' . preg_quote($className, '/') . '(?:\s|{|extends|implements)/';
return preg_match($pattern, $content) === 1;
}
/**
* Check if content contains actual PHP code
*/

View File

@@ -38,6 +38,7 @@ final readonly class FileStreamProcessor
callable $fileProcessor
): int {
$totalFiles = 0;
$failedFiles = [];
error_log("FileStreamProcessor: Processing directories: " . implode(', ', $directories));
@@ -45,6 +46,7 @@ final readonly class FileStreamProcessor
$directoryPath = FilePath::create($directory);
foreach ($this->streamPhpFiles($directoryPath) as $file) {
$fileContext = null;
try {
// Extract classes from file
$classNames = $this->classExtractor->extractFromFile($file);
@@ -67,11 +69,13 @@ final readonly class FileStreamProcessor
$this->processingContext->maybeCollectGarbage($totalFiles);
} catch (\Throwable $e) {
// Only log errors, not every processed file
$this->logger?->warning(
"Failed to process file {$file->getPath()->toString()}: {$e->getMessage()} in FileStreamProcessor",
LogContext::withException($e)
);
// Collect error information for aggregated reporting
$filePath = $file->getPath()->toString();
$failedFiles[] = [
'file' => $filePath,
'error' => $e->getMessage(),
'type' => $e::class,
];
} finally {
// Always cleanup after processing a file
$this->processingContext->cleanup();
@@ -79,6 +83,28 @@ final readonly class FileStreamProcessor
}
}
// Log aggregated summary instead of individual errors
if (!empty($failedFiles)) {
$failedCount = count($failedFiles);
$errorMessage = sprintf(
'Failed to read %d file(s) because of syntax errors or other issues.',
$failedCount
);
$context = LogContext::create()->withData([
'failed_files_count' => $failedCount,
'total_files_processed' => $totalFiles,
'failed_files' => array_map(fn($f) => [
'file' => $f['file'],
'error' => $f['error'],
'type' => $f['type'],
], $failedFiles)
]);
$this->logger?->warning($errorMessage, $context);
error_log("FileStreamProcessor: {$errorMessage}");
}
error_log("FileStreamProcessor: Total files processed: " . $totalFiles);
return $totalFiles;

View File

@@ -16,6 +16,7 @@ use App\Framework\Discovery\ValueObjects\TemplateMapping;
use App\Framework\Filesystem\File;
use App\Framework\Http\Method;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Coordinates visitor execution with shared reflection context
@@ -72,19 +73,41 @@ final class VisitorCoordinator
FileContext $fileContext,
DiscoveryDataCollector $collector
): void {
// Get shared reflection instance
$reflection = $this->processingContext->getReflection($className);
if ($reflection === null) {
return;
}
try {
// Get shared reflection instance
$reflection = $this->processingContext->getReflection($className);
if ($reflection === null) {
return;
}
// Process attributes
$this->processClassAttributes($className, $fileContext, $reflection, $collector);
$this->processMethodAttributes($className, $fileContext, $reflection, $collector);
// Process attributes
$this->processClassAttributes($className, $fileContext, $reflection, $collector);
$this->processMethodAttributes($className, $fileContext, $reflection, $collector);
// Process interface implementations
if (! empty($this->targetInterfaces)) {
$this->processInterfaces($className, $reflection, $collector);
// Process interface implementations
if (! empty($this->targetInterfaces)) {
$this->processInterfaces($className, $reflection, $collector);
}
} catch (\Throwable $e) {
$errorMessage = sprintf(
'Failed to process class "%s" in file %s: %s',
$className->getFullyQualified(),
$fileContext->path->toString(),
$e->getMessage()
);
$context = LogContext::withException($e)->withData([
'class_name' => $className->getFullyQualified(),
'file_path' => $fileContext->path->toString(),
'exception_class' => $e::class,
'exception_file' => $e->getFile(),
'exception_line' => $e->getLine(),
]);
$this->logger?->warning($errorMessage, $context);
error_log($errorMessage);
// Don't re-throw - continue processing other classes
}
}
@@ -130,29 +153,78 @@ final class VisitorCoordinator
$reflection,
DiscoveryDataCollector $collector
): void {
foreach ($reflection->getMethods() as $method) {
foreach ($method->getAttributes() as $attribute) {
$attributeClass = $attribute->getName();
try {
$methods = $reflection->getMethods();
} catch (\Throwable $e) {
$errorMessage = sprintf(
'Failed to get methods for class "%s" in file %s: %s',
$className->getFullyQualified(),
$fileContext->path->toString(),
$e->getMessage()
);
$context = LogContext::withException($e)->withData([
'class_name' => $className->getFullyQualified(),
'file_path' => $fileContext->path->toString(),
'exception_class' => $e::class,
'exception_file' => $e->getFile(),
'exception_line' => $e->getLine(),
]);
$this->logger?->warning($errorMessage, $context);
error_log($errorMessage);
// Return early if we can't get methods
return;
}
foreach ($methods as $method) {
try {
foreach ($method->getAttributes() as $attribute) {
$attributeClass = $attribute->getName();
if ($this->shouldIgnoreAttribute($attributeClass)) {
continue;
if ($this->shouldIgnoreAttribute($attributeClass)) {
continue;
}
$mappedData = $this->applyMapper($attributeClass, $method, $attribute);
$discovered = new DiscoveredAttribute(
className: $className,
attributeClass: $attributeClass,
target: AttributeTarget::METHOD,
methodName: MethodName::create($method->getName()),
propertyName: null,
arguments: $this->extractAttributeArguments($attribute),
filePath: $fileContext->path,
additionalData: $mappedData ?? []
);
$collector->getAttributeRegistry()->add($attributeClass, $discovered);
}
$mappedData = $this->applyMapper($attributeClass, $method, $attribute);
$discovered = new DiscoveredAttribute(
className: $className,
attributeClass: $attributeClass,
target: AttributeTarget::METHOD,
methodName: MethodName::create($method->getName()),
propertyName: null,
arguments: $this->extractAttributeArguments($attribute),
filePath: $fileContext->path,
additionalData: $mappedData ?? []
} catch (\Throwable $e) {
$errorMessage = sprintf(
'Failed to process method "%s" in class "%s" in file %s: %s',
$method->getName(),
$className->getFullyQualified(),
$fileContext->path->toString(),
$e->getMessage()
);
$collector->getAttributeRegistry()->add($attributeClass, $discovered);
$context = LogContext::withException($e)->withData([
'class_name' => $className->getFullyQualified(),
'method_name' => $method->getName(),
'file_path' => $fileContext->path->toString(),
'exception_class' => $e::class,
'exception_file' => $e->getFile(),
'exception_line' => $e->getLine(),
]);
$this->logger?->warning($errorMessage, $context);
error_log($errorMessage);
// Continue with next method
continue;
}
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Framework\Examples;
use App\Framework\Attributes\Route;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Http\Method;
use App\Framework\Mcp\McpTool;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\JsonResult;
/**
* Example implementation of a multi-purpose action
*
* This class demonstrates how a single method can be used as:
* - MCP Tool (for AI integration)
* - Console Command (for CLI usage)
* - HTTP Route (for API access)
*
* Requirements:
* - Only builtin parameter types (string, int, bool, float, array)
* - Returns ActionResult (unified return type)
*/
final readonly class MultiPurposeAction
{
/**
* List users with filtering and pagination
*
* This method can be called via:
* - MCP: {"name": "list_users", "arguments": {"status": "active", "limit": 10}}
* - Console: users:list --status=active --limit=10
* - HTTP: GET /api/users?status=active&limit=10
*/
#[McpTool(
name: 'list_users',
description: 'List users with optional filtering and pagination'
)]
#[ConsoleCommand(
name: 'users:list',
description: 'List users with optional filtering and pagination'
)]
#[Route(
path: '/api/users',
method: Method::GET
)]
public function listUsers(
string $status = 'active',
int $limit = 10,
bool $includeDetails = false
): ActionResult {
// Simulate user data
$users = [
['id' => 1, 'name' => 'John Doe', 'status' => 'active'],
['id' => 2, 'name' => 'Jane Smith', 'status' => 'active'],
['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive'],
];
// Filter by status
$filteredUsers = array_filter($users, fn($user) => $user['status'] === $status);
// Limit results
$limitedUsers = array_slice($filteredUsers, 0, $limit);
// Add details if requested
if ($includeDetails) {
foreach ($limitedUsers as &$user) {
$user['email'] = strtolower(str_replace(' ', '.', $user['name'])) . '@example.com';
$user['created_at'] = '2024-01-01';
}
}
// Return unified ActionResult
return new JsonResult([
'users' => array_values($limitedUsers),
'total' => count($limitedUsers),
'status' => $status,
'limit' => $limit,
]);
}
/**
* Get user by ID
*
* This method can be called via:
* - MCP: {"name": "get_user", "arguments": {"userId": "1"}}
* - Console: users:get --userId=1
* - HTTP: GET /api/users/1
*/
#[McpTool(
name: 'get_user',
description: 'Get a specific user by ID'
)]
#[ConsoleCommand(
name: 'users:get',
description: 'Get a specific user by ID'
)]
#[Route(
path: '/api/users/{userId}',
method: Method::GET
)]
public function getUser(int $userId): ActionResult
{
// Simulate user data
$users = [
1 => ['id' => 1, 'name' => 'John Doe', 'status' => 'active', 'email' => 'john.doe@example.com'],
2 => ['id' => 2, 'name' => 'Jane Smith', 'status' => 'active', 'email' => 'jane.smith@example.com'],
3 => ['id' => 3, 'name' => 'Bob Johnson', 'status' => 'inactive', 'email' => 'bob.johnson@example.com'],
];
if (! isset($users[$userId])) {
return new JsonResult([
'error' => 'User not found',
'user_id' => $userId,
]);
}
return new JsonResult([
'user' => $users[$userId],
]);
}
/**
* Create a new user
*
* This method can be called via:
* - MCP: {"name": "create_user", "arguments": {"name": "Alice", "email": "alice@example.com"}}
* - Console: users:create --name=Alice --email=alice@example.com
* - HTTP: POST /api/users with JSON body
*/
#[McpTool(
name: 'create_user',
description: 'Create a new user'
)]
#[ConsoleCommand(
name: 'users:create',
description: 'Create a new user'
)]
#[Route(
path: '/api/users',
method: Method::POST
)]
public function createUser(
string $name,
string $email,
bool $active = true
): ActionResult {
// Simulate user creation
$newUser = [
'id' => 4,
'name' => $name,
'email' => $email,
'status' => $active ? 'active' : 'inactive',
'created_at' => date('Y-m-d H:i:s'),
];
return new JsonResult([
'user' => $newUser,
'message' => 'User created successfully',
]);
}
}

View File

@@ -99,7 +99,7 @@ final readonly class WafMiddleware implements HttpMiddleware
// Debug log analysis result
$this->logger->debug('WAF analysis complete', LogContext::withData([
'result_status' => $wafResult->getStatus()->value ?? 'unknown',
'result_status' => $wafResult->status->value ?? 'unknown',
'result_action' => $wafResult->getAction(),
'layer_name' => $wafResult->getLayerName(),
'message' => $wafResult->getMessage(),

View File

@@ -58,6 +58,148 @@ final readonly class CurlHttpClient implements HttpClient
// Wrap any exception in CurlExecutionFailed for backward compatibility
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
}
}
/**
* Send request with streaming response to destination
*
* Streams HTTP response directly to a writable stream resource.
* Useful for large file downloads without loading entire response into memory.
*
* @param ClientRequest $request HTTP request to send
* @param resource $destination Writable stream resource (e.g., fopen('file.txt', 'w'))
* @return StreamingResponse Response with headers and status, but no body (streamed to destination)
* @throws CurlExecutionFailed If request execution fails
*/
public function sendStreaming(ClientRequest $request, $destination): StreamingResponse
{
if (! is_resource($destination)) {
throw new \InvalidArgumentException('Destination must be a valid stream resource');
}
$handle = new Handle();
try {
// Build options using HandleOption enum
$options = $this->requestBuilder->buildOptions($request);
// Remove CURLOPT_RETURNTRANSFER (we're streaming to destination)
unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]);
// Handle authentication
if ($request->options->auth !== null) {
$authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers);
if ($authResult->headers !== $request->headers) {
$updatedRequest = $request->with(['headers' => $authResult->headers]);
$options = $this->requestBuilder->buildOptions($updatedRequest);
unset($options[\App\Framework\HttpClient\Curl\HandleOption::ReturnTransfer->value]);
}
if (! empty($authResult->curlOptions)) {
$options = array_replace($options, $authResult->curlOptions);
}
}
// Enable header capture for streaming
$headerBuffer = '';
$headerFunction = function ($ch, $header) use (&$headerBuffer) {
$headerBuffer .= $header;
return strlen($header);
};
$options[\App\Framework\HttpClient\Curl\HandleOption::HeaderFunction->value] = $headerFunction;
// Set all options
$handle->setOptions($options);
// Execute and stream directly to destination
$handle->execute($destination);
// Parse response headers from buffer
$statusCode = $handle->getInfo(\App\Framework\HttpClient\Curl\Info::ResponseCode);
$status = \App\Framework\Http\Status::from((int) $statusCode);
$headers = $this->responseParser->parseHeaders($headerBuffer);
// Get bytes written (if available)
$bytesWritten = (int) ($handle->getInfo(\App\Framework\HttpClient\Curl\Info::SizeDownload) ?: 0);
return new StreamingResponse(
status: $status,
headers: $headers,
bytesWritten: $bytesWritten
);
} catch (\Throwable $e) {
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
}
}
/**
* Send request with streaming body from source
*
* Streams HTTP request body from a readable stream resource.
* Useful for large file uploads without loading entire file into memory.
*
* @param ClientRequest $request HTTP request to send (body will be replaced by stream)
* @param resource $source Readable stream resource (e.g., fopen('file.txt', 'r'))
* @param int|null $contentLength Content-Length in bytes (null for chunked transfer)
* @return ClientResponse HTTP response
* @throws CurlExecutionFailed If request execution fails
*/
public function sendStreamingUpload(ClientRequest $request, $source, ?int $contentLength = null): ClientResponse
{
if (! is_resource($source)) {
throw new \InvalidArgumentException('Source must be a valid stream resource');
}
$handle = new Handle();
try {
// Build options using HandleOption enum
$options = $this->requestBuilder->buildOptions($request);
// Remove CURLOPT_POSTFIELDS (we're using stream)
unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]);
// Set streaming upload options
$options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true;
$options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source;
if ($contentLength !== null) {
$options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength;
}
// Handle authentication
if ($request->options->auth !== null) {
$authResult = $this->authenticationHandler->configure($request->options->auth, $request->headers);
if ($authResult->headers !== $request->headers) {
$updatedRequest = $request->with(['headers' => $authResult->headers]);
$options = array_replace($options, $this->requestBuilder->buildOptions($updatedRequest));
unset($options[\App\Framework\HttpClient\Curl\HandleOption::PostFields->value]);
$options[\App\Framework\HttpClient\Curl\HandleOption::Upload->value] = true;
$options[\App\Framework\HttpClient\Curl\HandleOption::InFile->value] = $source;
if ($contentLength !== null) {
$options[\App\Framework\HttpClient\Curl\HandleOption::InFileSize->value] = $contentLength;
}
}
if (! empty($authResult->curlOptions)) {
$options = array_replace($options, $authResult->curlOptions);
}
}
// Set all options
$handle->setOptions($options);
// Execute request (automatically uses CURLOPT_RETURNTRANSFER)
$rawResponse = $handle->fetch();
// Parse response
return $this->responseParser->parse($rawResponse, $handle->getResource());
} catch (\Throwable $e) {
throw new CurlExecutionFailed($e->getMessage(), $e->getCode(), $e);
}
}
@@ -127,6 +269,7 @@ final readonly class CurlHttpClient implements HttpClient
status: Status::from($status),
headers: $headers,
body: $body
);*/
);
}
*/
}

View File

@@ -23,7 +23,7 @@ final readonly class CurlResponseParser
return new ClientResponse($status, $headers, $body);
}
private function parseHeaders(string $headersRaw): Headers
public function parseHeaders(string $headersRaw): Headers
{
$headers = new Headers();
$lines = explode("\r\n", trim($headersRaw));

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\HttpClient;
use App\Framework\Http\Headers;
use App\Framework\Http\Status;
/**
* Response from streaming HTTP request
*
* Contains status and headers, but no body (body was streamed to destination).
* Includes bytesWritten count for verification.
*/
final readonly class StreamingResponse
{
public function __construct(
public Status $status,
public Headers $headers,
public int $bytesWritten = 0
) {
}
public function isSuccessful(): bool
{
return $this->status->isSuccess();
}
}

View File

@@ -4,29 +4,36 @@ declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\LogEntry;
/**
* In-memory log handler for testing
*
* Stores all log entries in memory for inspection and assertions in tests
* Stores all log records in memory for inspection and assertions in tests
*/
final class InMemoryHandler implements LogHandler
{
/** @var LogEntry[] */
/** @var LogRecord[] */
private array $entries = [];
public function handle(LogEntry $entry): void
public function isHandling(LogRecord $record): bool
{
$this->entries[] = $entry;
// In-memory handler captures all log records for testing
return true;
}
public function handle(LogRecord $record): void
{
$this->entries[] = $record;
}
/**
* Get all logged entries
*
* @return LogEntry[]
* @return LogRecord[]
*/
public function getEntries(): array
{
@@ -36,13 +43,13 @@ final class InMemoryHandler implements LogHandler
/**
* Get entries by log level
*
* @return LogEntry[]
* @return LogRecord[]
*/
public function getEntriesByLevel(LogLevel $level): array
{
return array_filter(
$this->entries,
fn(LogEntry $entry) => $entry->level === $level
fn(LogRecord $entry) => $entry->level === $level
);
}

View File

@@ -36,7 +36,7 @@ final class PerformanceProcessor implements LogProcessor
}
}
public function process(LogRecord $record): LogRecord
public function processRecord(LogRecord $record): LogRecord
{
$performance = [];
@@ -54,3 +54,22 @@ final class PerformanceProcessor implements LogProcessor
$elapsed = Timestamp::now()->diffInMilliseconds(self::$requestStartTime);
$performance['execution_time_ms'] = round($elapsed, 2);
}
// Merge performance data into log record context
return $record->withContext(array_merge(
$record->context,
['performance' => $performance]
));
}
public function getPriority(): int
{
// Low priority - add performance data at the end
return 10;
}
public function getName(): string
{
return 'performance';
}
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Core\ValueObjects;
use App\Framework\Router\ActionResult;
/**
* Value Object für MCP Tool Ergebnisse
*
* Standardisiert die Rückgabe aller MCP Tools
*/
final readonly class ToolResult
final readonly class ToolResult implements ActionResult
{
public function __construct(
public mixed $data,

View File

@@ -110,13 +110,24 @@ final readonly class McpServer
$result = $instance->$method(...$this->prepareArguments($tool['parameters'], $arguments));
// Handle ActionResult - convert to ToolResult if needed
if ($result instanceof \App\Framework\Router\ActionResult) {
$toolResult = $this->convertActionResultToToolResult($result);
} elseif ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
$toolResult = $result;
} else {
// Convert plain result to ToolResult
$toolResult = \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result);
}
// Convert ToolResult to MCP response
$response = [
'jsonrpc' => '2.0',
'result' => [
'content' => [
[
'type' => 'text',
'text' => is_string($result) ? $result : json_encode($result, JSON_PRETTY_PRINT),
'text' => json_encode($toolResult->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
],
],
],
@@ -130,6 +141,35 @@ final readonly class McpServer
}
}
/**
* Convert ActionResult to ToolResult
*/
private function convertActionResultToToolResult(\App\Framework\Router\ActionResult $result): \App\Framework\Mcp\Core\ValueObjects\ToolResult
{
// If it's already a ToolResult, return it
if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
return $result;
}
// Convert JsonResult to ToolResult
if ($result instanceof \App\Framework\Router\Result\JsonResult) {
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data);
}
// Convert ConsoleResult to ToolResult
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
$success = $result->exitCode->value === 0;
$message = $result->data['message'] ?? ($success ? 'Command executed successfully' : 'Command failed');
return $success
? \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data, ['message' => $message])
: \App\Framework\Mcp\Core\ValueObjects\ToolResult::failure($message, $result->data);
}
// Default: convert to success ToolResult
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success(['result' => 'Operation completed']);
}
private function listResources($requestId = null): string
{
$resources = [];

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Mcp;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Core\AttributeMapper;
use App\Framework\Core\ParameterTypeValidator;
use App\Framework\Attributes\Route;
use App\Framework\Mcp\Core\ValueObjects\OutputFormat;
use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod;
@@ -15,6 +18,13 @@ use ReflectionUnionType;
final readonly class McpToolMapper implements AttributeMapper
{
private ParameterTypeValidator $typeValidator;
public function __construct()
{
$this->typeValidator = new ParameterTypeValidator();
}
public function getAttributeClass(): string
{
return McpTool::class;
@@ -28,6 +38,26 @@ final readonly class McpToolMapper implements AttributeMapper
$class = $reflectionTarget->getDeclaringClass();
// Check if method has multiple attributes (multi-purpose)
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
// If multi-purpose, validate that all parameters are builtin types
if ($hasMultipleAttributes) {
$parameters = $reflectionTarget->getParameters()->toArray();
$reflectionParameters = [];
foreach ($parameters as $param) {
$reflectionParameters[] = $param->getType();
}
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
// Skip this attribute if parameters are not all builtin
return null;
}
}
// Get other attributes for metadata
$otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget);
return [
'name' => $attributeInstance->name,
'description' => $attributeInstance->description,
@@ -35,9 +65,56 @@ final readonly class McpToolMapper implements AttributeMapper
'class' => $class->getFullyQualified(),
'method' => $reflectionTarget->getName(),
'parameters' => $this->extractParameters($reflectionTarget),
'multi_purpose' => $hasMultipleAttributes,
'other_attributes' => $otherAttributes,
];
}
/**
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
*/
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
{
$attributes = $method->getAttributes();
$purposeAttributeCount = 0;
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
McpTool::class,
ConsoleCommand::class,
Route::class,
], true)) {
$purposeAttributeCount++;
}
}
return $purposeAttributeCount > 1;
}
/**
* Get other purpose attributes on the same method
*
* @return array<string>
*/
private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array
{
$attributes = $method->getAttributes();
$otherAttributes = [];
foreach ($attributes as $attribute) {
$attributeName = $attribute->getName();
if (in_array($attributeName, [
ConsoleCommand::class,
Route::class,
], true)) {
$otherAttributes[] = $attributeName;
}
}
return $otherAttributes;
}
private function generateInputSchema(WrappedReflectionMethod $method, McpTool $tool): array
{
$schema = [
@@ -262,7 +339,7 @@ final readonly class McpToolMapper implements AttributeMapper
$type = $param->getType();
$parameters[] = [
'name' => $param->getName(),
'type' => $type ? $type->getName() : 'mixed',
'type' => $type ? ($type instanceof \ReflectionNamedType ? $type->getName() : 'mixed') : 'mixed',
'required' => ! $param->isOptional(),
'default' => $param->isOptional() ? $param->getDefaultValue() : null,
];

View File

@@ -4,26 +4,29 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\HttpClient\HttpClient;
use App\Framework\HttpClient\HttpMethod;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Mcp\McpTool;
use App\Framework\Router\GenericResult;
use App\Infrastructure\Api\Gitea\GiteaClient;
/**
* Gitea Repository Management MCP Tools
*
* Provides AI-accessible Gitea API operations for repository management,
* SSH key setup, and deployment automation.
* issue tracking, CI/CD workflows, and deployment automation.
*
* Architecture: Leverages Infrastructure/Api/Gitea service classes for
* clean separation and better maintainability.
*/
final readonly class GiteaTools
{
public function __construct(
private HttpClient $httpClient,
private string $giteaUrl,
private string $giteaUsername,
private string $giteaPassword
private GiteaClient $giteaClient
) {
}
// ==================== Repository Management ====================
#[McpTool(
name: 'gitea_create_repository',
description: 'Create a new repository in Gitea'
@@ -35,67 +38,51 @@ final readonly class GiteaTools
bool $autoInit = false,
string $defaultBranch = 'main'
): array {
$url = "{$this->giteaUrl}/api/v1/user/repos";
try {
$data = [
'name' => $name,
'description' => $description,
'private' => $private,
'auto_init' => $autoInit,
'default_branch' => $defaultBranch,
];
$data = [
'name' => $name,
'description' => $description,
'private' => $private,
'auto_init' => $autoInit,
'default_branch' => $defaultBranch,
];
$repository = $this->giteaClient->repositories->create($data);
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'repository' => [
'name' => $result['response']['name'] ?? $name,
'full_name' => $result['response']['full_name'] ?? "{$this->giteaUsername}/$name",
'clone_url' => $result['response']['clone_url'] ?? null,
'ssh_url' => $result['response']['ssh_url'] ?? null,
'html_url' => $result['response']['html_url'] ?? null,
'private' => $result['response']['private'] ?? $private,
'id' => $result['response']['id'] ?? null,
],
'repository' => $this->formatRepository($repository),
];
} catch (\Exception $e) {
return $this->formatError($e, 'Failed to create repository');
}
return $result;
}
#[McpTool(
name: 'gitea_list_repositories',
description: 'List all repositories for the authenticated user'
)]
public function listRepositories(): array
public function listRepositories(int $page = 1, int $limit = 30): array
{
$url = "{$this->giteaUrl}/api/v1/user/repos";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$repos = array_map(function ($repo) {
return [
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
];
}, $result['response'] ?? []);
try {
$repositories = $this->giteaClient->repositories->list([
'page' => $page,
'limit' => $limit,
]);
return [
'success' => true,
'repositories' => $repos,
'count' => count($repos),
'repositories' => array_map(
fn($repo) => $this->formatRepository($repo),
$repositories
),
'count' => count($repositories),
'page' => $page,
'limit' => $limit,
];
} catch (\Exception $e) {
return $this->formatError($e, 'Failed to list repositories');
}
return $result;
}
#[McpTool(
@@ -104,198 +91,374 @@ final readonly class GiteaTools
)]
public function getRepository(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$repo = $result['response'];
try {
$repository = $this->giteaClient->repositories->get($owner, $repo);
return [
'success' => true,
'repository' => [
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
'default_branch' => $repo['default_branch'] ?? 'main',
'created_at' => $repo['created_at'] ?? null,
'updated_at' => $repo['updated_at'] ?? null,
],
'repository' => $this->formatRepository($repository),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get repository {$owner}/{$repo}");
}
}
return $result;
#[McpTool(
name: 'gitea_update_repository',
description: 'Update repository settings'
)]
public function updateRepository(
string $owner,
string $repo,
?string $description = null,
?bool $private = null,
?string $website = null,
?bool $hasIssues = null,
?bool $hasWiki = null,
?string $defaultBranch = null
): array {
try {
$data = array_filter([
'description' => $description,
'private' => $private,
'website' => $website,
'has_issues' => $hasIssues,
'has_wiki' => $hasWiki,
'default_branch' => $defaultBranch,
], fn($value) => $value !== null);
$repository = $this->giteaClient->repositories->update($owner, $repo, $data);
return [
'success' => true,
'repository' => $this->formatRepository($repository),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to update repository {$owner}/{$repo}");
}
}
#[McpTool(
name: 'gitea_delete_repository',
description: 'Delete a repository'
description: 'Delete a repository (DANGEROUS - permanent deletion)'
)]
public function deleteRepository(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
try {
$this->giteaClient->repositories->delete($owner, $repo);
return $this->makeRequest(HttpMethod::DELETE, $url);
return [
'success' => true,
'message' => "Repository {$owner}/{$repo} deleted successfully",
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to delete repository {$owner}/{$repo}");
}
}
// ==================== Issue Management ====================
#[McpTool(
name: 'gitea_add_deploy_key',
description: 'Add an SSH deploy key to a repository'
name: 'gitea_create_issue',
description: 'Create a new issue in a repository'
)]
public function addDeployKey(
public function createIssue(
string $owner,
string $repo,
string $title,
string $key,
bool $readOnly = true
string $body = '',
?array $labels = null,
?array $assignees = null,
?int $milestone = null
): array {
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
try {
$data = array_filter([
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => $assignees,
'milestone' => $milestone,
], fn($value) => $value !== null);
$data = [
'title' => $title,
'key' => $key,
'read_only' => $readOnly,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'deploy_key' => [
'id' => $result['response']['id'] ?? null,
'title' => $result['response']['title'] ?? $title,
'key' => $result['response']['key'] ?? $key,
'read_only' => $result['response']['read_only'] ?? $readOnly,
'created_at' => $result['response']['created_at'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_list_deploy_keys',
description: 'List all deploy keys for a repository'
)]
public function listDeployKeys(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$keys = array_map(function ($key) {
return [
'id' => $key['id'] ?? null,
'title' => $key['title'] ?? 'unknown',
'key' => $key['key'] ?? '',
'read_only' => $key['read_only'] ?? true,
'created_at' => $key['created_at'] ?? null,
];
}, $result['response'] ?? []);
$issue = $this->giteaClient->issues->create($owner, $repo, $data);
return [
'success' => true,
'deploy_keys' => $keys,
'count' => count($keys),
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to create issue in {$owner}/{$repo}");
}
return $result;
}
#[McpTool(
name: 'gitea_delete_deploy_key',
description: 'Delete a deploy key from a repository'
name: 'gitea_list_issues',
description: 'List issues in a repository'
)]
public function deleteDeployKey(string $owner, string $repo, int $keyId): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys/$keyId";
public function listIssues(
string $owner,
string $repo,
string $state = 'open',
?array $labels = null,
int $page = 1,
int $limit = 30
): array {
try {
$options = array_filter([
'state' => $state,
'labels' => $labels ? implode(',', $labels) : null,
'page' => $page,
'limit' => $limit,
], fn($value) => $value !== null);
return $this->makeRequest(HttpMethod::DELETE, $url);
}
#[McpTool(
name: 'gitea_add_user_ssh_key',
description: 'Add an SSH key to the authenticated user'
)]
public function addUserSshKey(string $title, string $key, bool $readOnly = false): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys";
$data = [
'title' => $title,
'key' => $key,
'read_only' => $readOnly,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'ssh_key' => [
'id' => $result['response']['id'] ?? null,
'title' => $result['response']['title'] ?? $title,
'key' => $result['response']['key'] ?? $key,
'read_only' => $result['response']['read_only'] ?? $readOnly,
'created_at' => $result['response']['created_at'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_list_user_ssh_keys',
description: 'List all SSH keys for the authenticated user'
)]
public function listUserSshKeys(): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$keys = array_map(function ($key) {
return [
'id' => $key['id'] ?? null,
'title' => $key['title'] ?? 'unknown',
'key' => $key['key'] ?? '',
'fingerprint' => $key['fingerprint'] ?? '',
'read_only' => $key['read_only'] ?? false,
'created_at' => $key['created_at'] ?? null,
];
}, $result['response'] ?? []);
$issues = $this->giteaClient->issues->list($owner, $repo, $options);
return [
'success' => true,
'ssh_keys' => $keys,
'count' => count($keys),
'issues' => array_map(
fn($issue) => $this->formatIssue($issue),
$issues
),
'count' => count($issues),
'state' => $state,
'page' => $page,
'limit' => $limit,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to list issues in {$owner}/{$repo}");
}
return $result;
}
#[McpTool(
name: 'gitea_delete_user_ssh_key',
description: 'Delete an SSH key from the authenticated user'
name: 'gitea_get_issue',
description: 'Get details of a specific issue'
)]
public function deleteUserSshKey(int $keyId): array
public function getIssue(string $owner, string $repo, int $index): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys/$keyId";
try {
$issue = $this->giteaClient->issues->get($owner, $repo, $index);
return $this->makeRequest(HttpMethod::DELETE, $url);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get issue #{$index} in {$owner}/{$repo}");
}
}
#[McpTool(
name: 'gitea_update_issue',
description: 'Update an existing issue'
)]
public function updateIssue(
string $owner,
string $repo,
int $index,
?string $title = null,
?string $body = null,
?string $state = null,
?array $labels = null,
?array $assignees = null
): array {
try {
$data = array_filter([
'title' => $title,
'body' => $body,
'state' => $state,
'labels' => $labels,
'assignees' => $assignees,
], fn($value) => $value !== null);
$issue = $this->giteaClient->issues->update($owner, $repo, $index, $data);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to update issue #{$index} in {$owner}/{$repo}");
}
}
#[McpTool(
name: 'gitea_close_issue',
description: 'Close an issue'
)]
public function closeIssue(string $owner, string $repo, int $index): array
{
try {
$issue = $this->giteaClient->issues->close($owner, $repo, $index);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
'message' => "Issue #{$index} closed successfully",
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to close issue #{$index} in {$owner}/{$repo}");
}
}
// ==================== CI/CD & Actions ====================
#[McpTool(
name: 'gitea_list_workflows',
description: 'List all workflows in a repository'
)]
#[ConsoleCommand(
name: 'gitea:workflows:list',
description: 'List all workflows in a Gitea repository'
)]
public function listWorkflows(string $owner, string $repo): GenericResult
{
try {
$workflows = $this->giteaClient->actions->listWorkflows($owner, $repo);
return GenericResult::success([
'workflows' => $workflows['workflows'] ?? [],
'count' => count($workflows['workflows'] ?? []),
]);
} catch (\Exception $e) {
return GenericResult::fromException($e);
}
}
#[McpTool(
name: 'gitea_trigger_workflow',
description: 'Manually trigger a workflow'
)]
public function triggerWorkflow(
string $owner,
string $repo,
string $workflowId,
?string $ref = null,
?array $inputs = null
): array {
try {
$this->giteaClient->actions->triggerWorkflow(
$owner,
$repo,
$workflowId,
$inputs ?? [],
$ref
);
return [
'success' => true,
'message' => "Workflow {$workflowId} triggered successfully",
'workflow_id' => $workflowId,
'ref' => $ref,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to trigger workflow {$workflowId}");
}
}
#[McpTool(
name: 'gitea_list_workflow_runs',
description: 'List workflow runs for a repository'
)]
#[ConsoleCommand(
name: 'gitea:workflows:runs',
description: 'List workflow runs for a Gitea repository'
)]
public function listWorkflowRuns(
string $owner,
string $repo,
?string $status = null,
?int $workflowId = null,
int $page = 1,
int $limit = 30
): GenericResult {
try {
$options = array_filter([
'status' => $status,
'workflow_id' => $workflowId,
'page' => $page,
'limit' => $limit,
], fn($value) => $value !== null);
$runs = $this->giteaClient->actions->listRuns($owner, $repo, $options);
return GenericResult::success([
'runs' => array_map(
fn($run) => $this->formatWorkflowRun($run),
$runs['workflow_runs'] ?? []
),
'count' => count($runs['workflow_runs'] ?? []),
'page' => $page,
'limit' => $limit,
]);
} catch (\Exception $e) {
return GenericResult::fromException($e);
}
}
#[McpTool(
name: 'gitea_get_workflow_run',
description: 'Get details of a specific workflow run'
)]
#[ConsoleCommand(
name: 'gitea:workflows:run',
description: 'Get details of a specific Gitea workflow run'
)]
public function getWorkflowRun(string $owner, string $repo, int $runId): GenericResult
{
try {
$run = $this->giteaClient->actions->getRun($owner, $repo, $runId);
return GenericResult::success([
'run' => $this->formatWorkflowRun($run),
]);
} catch (\Exception $e) {
return GenericResult::fromException($e, [
'context' => "Failed to get workflow run #{$runId}",
]);
}
}
#[McpTool(
name: 'gitea_cancel_workflow_run',
description: 'Cancel a running workflow'
)]
public function cancelWorkflowRun(string $owner, string $repo, int $runId): array
{
try {
$this->giteaClient->actions->cancelRun($owner, $repo, $runId);
return [
'success' => true,
'message' => "Workflow run #{$runId} cancelled successfully",
'run_id' => $runId,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to cancel workflow run #{$runId}");
}
}
#[McpTool(
name: 'gitea_get_workflow_logs',
description: 'Get logs of a workflow run'
)]
public function getWorkflowLogs(string $owner, string $repo, int $runId): array
{
try {
$logs = $this->giteaClient->actions->getLogs($owner, $repo, $runId);
return [
'success' => true,
'logs' => $logs,
'run_id' => $runId,
'size' => strlen($logs),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get workflow logs for run #{$runId}");
}
}
// ==================== Git Remote Integration ====================
#[McpTool(
name: 'gitea_add_remote',
description: 'Add Gitea repository as git remote'
@@ -306,150 +469,130 @@ final readonly class GiteaTools
string $repo,
bool $useSsh = true
): array {
// Get repository info first
$repoInfo = $this->getRepository($owner, $repo);
try {
// Get repository info first
$repository = $this->giteaClient->repositories->get($owner, $repo);
if (! $repoInfo['success']) {
return $repoInfo;
}
$url = $useSsh
? $repository['ssh_url']
: $repository['clone_url'];
$url = $useSsh
? $repoInfo['repository']['ssh_url']
: $repoInfo['repository']['clone_url'];
if (! $url) {
return [
'success' => false,
'error' => 'Repository URL not found',
];
}
// Add remote via git command
$output = [];
$exitCode = 0;
$command = sprintf(
'git remote add %s %s 2>&1',
escapeshellarg($remoteName),
escapeshellarg($url)
);
exec($command, $output, $exitCode);
if ($exitCode !== 0) {
// Check if remote already exists
if (str_contains(implode("\n", $output), 'already exists')) {
if (!$url) {
return [
'success' => false,
'error' => 'Remote already exists',
'suggestion' => "Use 'git remote set-url $remoteName $url' to update",
'error' => 'Repository URL not found',
];
}
// Add remote via git command
$output = [];
$exitCode = 0;
$command = sprintf(
'git remote add %s %s 2>&1',
escapeshellarg($remoteName),
escapeshellarg($url)
);
exec($command, $output, $exitCode);
if ($exitCode !== 0) {
// Check if remote already exists
if (str_contains(implode("\n", $output), 'already exists')) {
return [
'success' => false,
'error' => 'Remote already exists',
'suggestion' => "Use 'git remote set-url {$remoteName} {$url}' to update",
];
}
return [
'success' => false,
'error' => 'Failed to add remote',
'output' => implode("\n", $output),
'exit_code' => $exitCode,
];
}
return [
'success' => false,
'error' => 'Failed to add remote',
'output' => implode("\n", $output),
'exit_code' => $exitCode,
];
}
return [
'success' => true,
'remote_name' => $remoteName,
'url' => $url,
'use_ssh' => $useSsh,
];
}
#[McpTool(
name: 'gitea_webhook_create',
description: 'Create a webhook for a repository'
)]
public function createWebhook(
string $owner,
string $repo,
string $url,
string $contentType = 'json',
array $events = ['push'],
bool $active = true,
?string $secret = null
): array {
$hookUrl = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/hooks";
$data = [
'type' => 'gitea',
'config' => [
'url' => $url,
'content_type' => $contentType,
'secret' => $secret ?? '',
],
'events' => $events,
'active' => $active,
];
$result = $this->makeRequest(HttpMethod::POST, $hookUrl, $data);
if ($result['success']) {
return [
'success' => true,
'webhook' => [
'id' => $result['response']['id'] ?? null,
'url' => $result['response']['config']['url'] ?? $url,
'events' => $result['response']['events'] ?? $events,
'active' => $result['response']['active'] ?? $active,
'created_at' => $result['response']['created_at'] ?? null,
],
'remote_name' => $remoteName,
'url' => $url,
'use_ssh' => $useSsh,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to add remote {$remoteName}");
}
return $result;
}
// ==================== Private Helper Methods ====================
private function makeRequest(HttpMethod $method, string $url, ?array $data = null): array
private function formatRepository(array $repo): array
{
try {
$options = [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => 'Basic ' . base64_encode("{$this->giteaUsername}:{$this->giteaPassword}"),
],
'verify_ssl' => false, // For self-signed certificates
];
return [
'id' => $repo['id'] ?? null,
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
'default_branch' => $repo['default_branch'] ?? 'main',
'created_at' => $repo['created_at'] ?? null,
'updated_at' => $repo['updated_at'] ?? null,
'stars_count' => $repo['stars_count'] ?? 0,
'forks_count' => $repo['forks_count'] ?? 0,
'open_issues_count' => $repo['open_issues_count'] ?? 0,
];
}
if ($data !== null) {
$options['json'] = $data;
}
private function formatIssue(array $issue): array
{
return [
'id' => $issue['id'] ?? null,
'number' => $issue['number'] ?? null,
'title' => $issue['title'] ?? 'Untitled',
'body' => $issue['body'] ?? '',
'state' => $issue['state'] ?? 'open',
'labels' => array_map(
fn($label) => $label['name'] ?? 'unknown',
$issue['labels'] ?? []
),
'assignees' => array_map(
fn($assignee) => $assignee['username'] ?? 'unknown',
$issue['assignees'] ?? []
),
'html_url' => $issue['html_url'] ?? null,
'created_at' => $issue['created_at'] ?? null,
'updated_at' => $issue['updated_at'] ?? null,
'closed_at' => $issue['closed_at'] ?? null,
];
}
$response = $this->httpClient->request($method, $url, $options);
private function formatWorkflowRun(array $run): array
{
return [
'id' => $run['id'] ?? null,
'name' => $run['name'] ?? 'Unnamed Workflow',
'status' => $run['status'] ?? 'unknown',
'conclusion' => $run['conclusion'] ?? null,
'workflow_id' => $run['workflow_id'] ?? null,
'event' => $run['event'] ?? null,
'head_branch' => $run['head_branch'] ?? null,
'head_sha' => $run['head_sha'] ?? null,
'html_url' => $run['html_url'] ?? null,
'created_at' => $run['created_at'] ?? null,
'updated_at' => $run['updated_at'] ?? null,
'run_started_at' => $run['run_started_at'] ?? null,
];
}
$statusCode = $response->getStatusCode();
$body = $response->getBody();
// Decode JSON response
$decoded = json_decode($body, true);
if ($statusCode >= 200 && $statusCode < 300) {
return [
'success' => true,
'response' => $decoded,
'http_code' => $statusCode,
];
}
return [
'success' => false,
'error' => $decoded['message'] ?? 'HTTP error ' . $statusCode,
'response' => $decoded,
'http_code' => $statusCode,
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => 'Request failed: ' . $e->getMessage(),
'exception' => get_class($e),
];
}
private function formatError(\Exception $e, string $context): array
{
return [
'success' => false,
'error' => $context,
'message' => $e->getMessage(),
'exception' => get_class($e),
];
}
}

View File

@@ -4,34 +4,25 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\Config\Environment;
use App\Framework\DI\Initializer;
use App\Framework\HttpClient\HttpClient;
use App\Infrastructure\Api\Gitea\GiteaClient;
/**
* Initializer for Gitea MCP Tools
*
* Registers GiteaTools with the DI container using the GiteaClient service.
* The GiteaClient is initialized separately with proper configuration via GiteaClientInitializer.
*/
final readonly class GiteaToolsInitializer
{
public function __construct(
private HttpClient $httpClient,
private Environment $environment
private GiteaClient $giteaClient
) {
}
#[Initializer]
public function __invoke(): GiteaTools
{
// Get Gitea configuration from environment
$giteaUrl = $this->environment->get('GITEA_URL', 'https://localhost:9443');
$giteaUsername = $this->environment->get('GITEA_USERNAME', 'michael');
$giteaPassword = $this->environment->get('GITEA_PASSWORD', 'GiteaAdmin2024');
return new GiteaTools(
$this->httpClient,
$giteaUrl,
$giteaUsername,
$giteaPassword
);
return new GiteaTools($this->giteaClient);
}
}

View File

@@ -92,8 +92,9 @@ final readonly class JobChainCommands
foreach ($status['job_statuses'] as $jobStatus) {
$canExecute = $jobStatus['can_execute'] ? '✅' : '⏳';
$depStatus = "{$jobStatus['dependencies_satisfied']}/{$jobStatus['dependencies_total']} deps";
$position = $jobStatus['position'] + 1;
echo " {$canExecute} Job {$jobStatus['position'] + 1}: {$jobStatus['job_id']} ({$depStatus})\n";
echo " {$canExecute} Job {$position}: {$jobStatus['job_id']} ({$depStatus})\n";
}
} catch (\Exception $e) {
@@ -167,7 +168,8 @@ final readonly class JobChainCommands
echo "🔗 {$statusIcon} {$chain['name']} ({$chain['chain_id']})\n";
echo " Status: {$chain['status']}\n";
echo " Mode: {$chain['execution_mode']}\n";
echo " Position: {$chain['job_position'] + 1}/{$chain['total_jobs']}\n";
$position = $chain['job_position'] + 1;
echo " Position: {$position}/{$chain['total_jobs']}\n";
if ($chain['next_job_after_current']) {
echo " Next job: {$chain['next_job_after_current']}\n";

View File

@@ -192,11 +192,12 @@ final readonly class JobDependencyCommands
foreach ($health['issues'] as $issue) {
echo " - {$issue['type']}: ";
match($issue['type']) {
'stalled_chain' => echo "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n",
'many_unsatisfied_dependencies' => echo "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n",
default => echo "Unknown issue\n"
$message = match($issue['type']) {
'stalled_chain' => "Chain {$issue['chain_id']} running for {$issue['hours_running']} hours\n",
'many_unsatisfied_dependencies' => "Job {$issue['job_id']} has {$issue['unsatisfied_count']} unsatisfied dependencies\n",
default => "Unknown issue\n"
};
echo $message;
}
}
}

View File

@@ -99,8 +99,22 @@ final class MethodCache implements \App\Framework\Reflection\Contracts\Reflectio
if (! isset($this->methodCache[$key])) {
/** @var class-string $classNameString */
$classNameString = $className->getFullyQualified();
$class = new \ReflectionClass($classNameString);
$this->methodCache[$key] = $class->getMethods($filter);
try {
$class = new \ReflectionClass($classNameString);
$this->methodCache[$key] = $class->getMethods($filter);
} catch (\ReflectionException $e) {
// Provide context about which class failed to reflect
$errorMessage = sprintf(
'Failed to reflect class "%s" in MethodCache::getNativeMethods(): %s',
$classNameString,
$e->getMessage()
);
error_log($errorMessage);
// Re-throw with more context
throw new \ReflectionException($errorMessage, $e->getCode(), $e);
}
}
return $this->methodCache[$key];

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Result\ConsoleResult;
use App\Framework\Console\Result\TextResult;
use App\Framework\Mcp\Core\ValueObjects\ToolResult;
/**
* Generic Action Result
*
* Universal result object that can be converted to both ToolResult (MCP)
* and ConsoleResult (console commands). This enables methods to have dual
* attributes (#[McpTool] and #[ConsoleCommand]) with a single return type.
*
* Architecture:
* - Methods return GenericResult
* - MCP context: Automatically converts to ToolResult
* - Console context: Automatically converts to ConsoleResult (TextResult)
*
* Example:
* #[McpTool(name: 'list_users')]
* #[ConsoleCommand(name: 'users:list')]
* public function listUsers(): GenericResult
* {
* return GenericResult::success(['users' => $users]);
* }
*/
final readonly class GenericResult implements ActionResult, ConsoleResult
{
public readonly array $data;
public readonly ExitCode $exitCode;
/**
* Create generic result
*
* Use factory methods (success/failure/error) for convenience.
*/
public function __construct(
public mixed $payload,
public bool $success,
public ?string $error = null,
public array $metadata = [],
?ExitCode $exitCode = null
) {
$this->exitCode = $exitCode ?? ($success ? ExitCode::SUCCESS : ExitCode::FAILURE);
$this->data = [
'success' => $this->success,
'payload' => $this->payload,
'error' => $this->error,
'metadata' => $this->metadata,
'exit_code' => $this->exitCode->value,
];
}
/**
* Create success result
*
* @param mixed $payload Result data
* @param array $metadata Additional metadata
*/
public static function success(mixed $payload, array $metadata = []): self
{
return new self(
payload: $payload,
success: true,
metadata: $metadata
);
}
/**
* Create failure result
*
* @param string $error Error message
* @param mixed $payload Optional partial data
* @param array $metadata Additional metadata
*/
public static function failure(string $error, mixed $payload = null, array $metadata = []): self
{
return new self(
payload: $payload,
success: false,
error: $error,
metadata: $metadata
);
}
/**
* Create failure from exception
*
* @param \Throwable $exception Exception to convert
* @param array $metadata Additional metadata
*/
public static function fromException(\Throwable $exception, array $metadata = []): self
{
return new self(
payload: null,
success: false,
error: $exception->getMessage(),
metadata: array_merge($metadata, [
'exception_type' => get_class($exception),
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
])
);
}
/**
* Convert to ToolResult for MCP context
*
* Automatic conversion when accessed via MCP tools.
*/
public function toToolResult(): ToolResult
{
if ($this->success) {
return ToolResult::success(
data: $this->payload,
metadata: $this->metadata
);
}
return ToolResult::failure(
error: $this->error ?? 'Unknown error',
data: $this->payload,
metadata: $this->metadata
);
}
/**
* Convert to ConsoleResult for console context
*
* Automatic conversion when accessed via console commands.
*/
public function toConsoleResult(): ConsoleResult
{
if ($this->success) {
$message = $this->formatSuccessMessage();
return TextResult::success($message);
}
return TextResult::error($this->error ?? 'Unknown error');
}
/**
* Render to console output (ConsoleResult interface)
*
* Delegates to TextResult for rendering.
*/
public function render(ConsoleOutputInterface $output): void
{
$this->toConsoleResult()->render($output);
}
/**
* Format success message for console output
*/
private function formatSuccessMessage(): string
{
if (is_string($this->payload)) {
return $this->payload;
}
if (is_array($this->payload)) {
// Check for common success message keys
if (isset($this->payload['message'])) {
return (string) $this->payload['message'];
}
// Format array data as JSON for readability
return json_encode($this->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
// Fallback: Generic success message
return 'Operation completed successfully';
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when a bucket is not found
*/
final class BucketNotFoundException extends StorageException
{
public function __construct(
string $bucket,
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('storage.bucket', 'ObjectStorage')
->withData(['bucket' => $bucket]);
parent::__construct(
message: "Bucket not found: {$bucket}",
context: $context,
errorCode: ErrorCode::NOT_FOUND,
previous: $previous,
code: 404
);
}
public static function for(string $bucket, ?\Throwable $previous = null): self
{
return new self($bucket, $previous);
}
public function getBucket(): string
{
return $this->getContext()->getData()['bucket'] ?? '';
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when an object is not found in storage
*/
final class ObjectNotFoundException extends StorageException
{
public function __construct(
string $bucket,
string $key,
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('storage.get', 'ObjectStorage')
->withData([
'bucket' => $bucket,
'key' => $key,
]);
parent::__construct(
message: "Object not found: {$bucket}/{$key}",
context: $context,
errorCode: ErrorCode::NOT_FOUND,
previous: $previous,
code: 404
);
}
public static function for(string $bucket, string $key, ?\Throwable $previous = null): self
{
return new self($bucket, $key, $previous);
}
public function getBucket(): string
{
return $this->getContext()->getData()['bucket'] ?? '';
}
public function getKey(): string
{
return $this->getContext()->getData()['key'] ?? '';
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when storage connection fails
*/
final class StorageConnectionException extends StorageException
{
public function __construct(
string $endpoint,
string $reason = '',
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('storage.connect', 'Storage')
->withData([
'endpoint' => $endpoint,
'reason' => $reason,
]);
$message = "Failed to connect to storage endpoint: {$endpoint}";
if ($reason !== '') {
$message .= " ({$reason})";
}
parent::__construct(
message: $message,
context: $context,
errorCode: ErrorCode::CONNECTION_FAILED,
previous: $previous,
code: 503
);
}
public static function for(string $endpoint, string $reason = '', ?\Throwable $previous = null): self
{
return new self($endpoint, $reason, $previous);
}
public function getEndpoint(): string
{
return $this->getContext()->getData()['endpoint'] ?? '';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\ExceptionMetadata;
use App\Framework\Exception\FrameworkException;
/**
* Base exception for Storage operations
*
* All storage-related exceptions should extend this class.
*/
class StorageException extends FrameworkException
{
public function __construct(
string $message,
ExceptionContext $context,
?ErrorCode $errorCode = null,
?\Throwable $previous = null,
int $code = 0,
?ExceptionMetadata $metadata = null
) {
parent::__construct($message, $context, $code, $previous, $errorCode, $metadata);
}
/**
* Create exception for bucket/key not found
*/
public static function notFound(string $bucket, string $key, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('storage.get', 'Storage')
->withData([
'bucket' => $bucket,
'key' => $key,
]);
return new self(
message: "Object not found: {$bucket}/{$key}",
context: $context,
errorCode: ErrorCode::NOT_FOUND,
previous: $previous,
code: 404
);
}
/**
* Create exception for connection errors
*/
public static function connectionFailed(string $endpoint, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('storage.connect', 'Storage')
->withData(['endpoint' => $endpoint]);
return new self(
message: "Failed to connect to storage endpoint: {$endpoint}",
context: $context,
errorCode: ErrorCode::CONNECTION_FAILED,
previous: $previous,
code: 503
);
}
/**
* Create exception for operation failures
*/
public static function operationFailed(string $operation, string $bucket, string $key, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation("storage.{$operation}", 'Storage')
->withData([
'bucket' => $bucket,
'key' => $key,
]);
return new self(
message: "Storage operation failed: {$operation} on {$bucket}/{$key}",
context: $context,
errorCode: ErrorCode::OPERATION_FAILED,
previous: $previous,
code: 500
);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when a storage operation fails
*/
final class StorageOperationException extends StorageException
{
public function __construct(
string $operation,
string $bucket,
string $key,
string $reason = '',
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation("storage.{$operation}", 'Storage')
->withData([
'bucket' => $bucket,
'key' => $key,
'reason' => $reason,
]);
$message = "Storage operation '{$operation}' failed for {$bucket}/{$key}";
if ($reason !== '') {
$message .= ": {$reason}";
}
parent::__construct(
message: $message,
context: $context,
errorCode: ErrorCode::OPERATION_FAILED,
previous: $previous,
code: 500
);
}
public static function for(string $operation, string $bucket, string $key, string $reason = '', ?\Throwable $previous = null): self
{
return new self($operation, $bucket, $key, $reason, $previous);
}
public function getOperation(): string
{
return $this->getContext()->getOperation();
}
public function getBucket(): string
{
return $this->getContext()->getData()['bucket'] ?? '';
}
public function getKey(): string
{
return $this->getContext()->getData()['key'] ?? '';
}
}

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Filesystem\Storage;
use App\Framework\Http\CustomMimeType;
use App\Framework\Http\MimeType;
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
use App\Framework\Storage\Exceptions\StorageOperationException;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use App\Framework\Storage\ValueObjects\ObjectMetadata;
use DateInterval;
/**
* Filesystem-based Object Storage implementation
*
* Maps S3-style buckets/keys to filesystem directories/files:
* - Buckets = directories
* - Keys = files within bucket directories
*/
final readonly class FilesystemObjectStorage implements ObjectStorage, StreamableObjectStorage
{
private string $basePath;
public function __construct(
private Storage $storage,
string $basePath = '/'
) {
// Normalize base path
$this->basePath = rtrim($basePath, '/');
}
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$path = $this->buildPath($bucket, $key);
try {
// Ensure bucket directory exists
$bucketPath = $this->buildBucketPath($bucket);
if (! $this->storage->exists($bucketPath)) {
$this->storage->createDirectory($bucketPath);
}
// Store content
$this->storage->put($path, $body);
// Get file metadata
$size = FileSize::fromBytes($this->storage->size($path));
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
$mimeTypeString = $this->storage->getMimeType($path);
// Generate ETag (SHA256 hash of content)
$etag = Hash::sha256($body);
// Convert MIME type string to Value Object
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $lastModified,
metadata: $metadata,
versionId: null
);
} catch (\Throwable $e) {
throw StorageOperationException::for('put', $bucket, $key, $e->getMessage(), $e);
}
}
public function get(string $bucket, string $key): string
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
return $this->storage->get($path);
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('get', $bucket, $key, $e->getMessage(), $e);
}
}
public function stream(string $bucket, string $key)
{
// Backward compatibility: returns temporary stream
return $this->openReadStream($bucket, $key);
}
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
// Validate destination stream
if (! is_resource($destination)) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
}
// Use Storage's readStream if available
if (method_exists($this->storage, 'readStream')) {
$sourceStream = $this->storage->readStream($path);
$bufferSize = $opts['bufferSize'] ?? 8192;
try {
$bytesWritten = stream_copy_to_stream($sourceStream, $destination, null, $bufferSize);
if ($bytesWritten === false) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to copy stream');
}
return $bytesWritten;
} finally {
fclose($sourceStream);
}
}
// Fallback: read content and write to stream
$content = $this->storage->get($path);
$bytesWritten = fwrite($destination, $content);
if ($bytesWritten === false) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream');
}
return $bytesWritten;
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (StorageOperationException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('getToStream', $bucket, $key, $e->getMessage(), $e);
}
}
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
{
$path = $this->buildPath($bucket, $key);
try {
// Validate source stream
if (! is_resource($source)) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
}
// Ensure bucket directory exists
$bucketPath = $this->buildBucketPath($bucket);
if (! $this->storage->exists($bucketPath)) {
$this->storage->createDirectory($bucketPath);
}
// Use Storage's putStream if available
if (method_exists($this->storage, 'putStream')) {
$this->storage->putStream($path, $source);
} else {
// Fallback: read stream content and use put()
$content = stream_get_contents($source);
if ($content === false) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream');
}
$this->storage->put($path, $content);
}
// Reuse head() logic to get ObjectInfo
return $this->head($bucket, $key);
} catch (StorageOperationException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('putFromStream', $bucket, $key, $e->getMessage(), $e);
}
}
public function openReadStream(string $bucket, string $key)
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
// Use Storage's readStream if available
if (method_exists($this->storage, 'readStream')) {
return $this->storage->readStream($path);
}
// Fallback: create stream from file content
$content = $this->storage->get($path);
$stream = fopen('php://temp', 'r+');
if ($stream === false) {
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
}
fwrite($stream, $content);
rewind($stream);
return $stream;
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('openReadStream', $bucket, $key, $e->getMessage(), $e);
}
}
public function head(string $bucket, string $key): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
throw ObjectNotFoundException::for($bucket, $key);
}
$size = FileSize::fromBytes($this->storage->size($path));
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
$mimeTypeString = $this->storage->getMimeType($path);
// Read content to generate ETag (could be optimized to read only if needed)
$content = $this->storage->get($path);
$etag = Hash::sha256($content);
// Convert MIME type string to Value Object
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $lastModified,
metadata: ObjectMetadata::empty(),
versionId: null
);
} catch (ObjectNotFoundException $e) {
throw $e;
} catch (\Throwable $e) {
throw StorageOperationException::for('head', $bucket, $key, $e->getMessage(), $e);
}
}
public function delete(string $bucket, string $key): void
{
$path = $this->buildPath($bucket, $key);
try {
if (! $this->storage->exists($path)) {
// Object doesn't exist, but that's OK for delete operations
return;
}
$this->storage->delete($path);
} catch (\Throwable $e) {
throw StorageOperationException::for('delete', $bucket, $key, $e->getMessage(), $e);
}
}
public function exists(string $bucket, string $key): bool
{
$path = $this->buildPath($bucket, $key);
return $this->storage->exists($path);
}
public function url(string $bucket, string $key): ?string
{
// Filesystem storage doesn't have public URLs
return null;
}
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
{
// Filesystem storage doesn't support presigned URLs
throw StorageOperationException::for(
'temporaryUrl',
$bucket,
$key,
'Temporary URLs are not supported for filesystem storage'
);
}
/**
* Build filesystem path for bucket/key
*/
private function buildPath(string $bucket, string $key): string
{
// Sanitize bucket name (prevent path traversal)
$bucket = $this->sanitizeBucketName($bucket);
$key = ltrim($key, '/');
// Sanitize key (prevent path traversal)
$key = $this->sanitizeKey($key);
return $this->basePath . '/' . $bucket . '/' . $key;
}
/**
* Build filesystem path for bucket directory
*/
private function buildBucketPath(string $bucket): string
{
$bucket = $this->sanitizeBucketName($bucket);
return $this->basePath . '/' . $bucket;
}
/**
* Sanitize bucket name to prevent path traversal
*/
private function sanitizeBucketName(string $bucket): string
{
// Remove any path separators and dangerous characters
$bucket = str_replace(['/', '\\', '..'], '', $bucket);
$bucket = trim($bucket, '.');
if ($bucket === '' || $bucket === '.') {
throw StorageOperationException::for('sanitize', $bucket, '', 'Invalid bucket name');
}
return $bucket;
}
/**
* Sanitize key to prevent path traversal
*/
private function sanitizeKey(string $key): string
{
// Remove leading slashes but preserve internal structure
$key = ltrim($key, '/');
// Prevent directory traversal
if (str_contains($key, '..')) {
throw StorageOperationException::for('sanitize', '', $key, 'Path traversal detected in key');
}
return $key;
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\CustomMimeType;
use App\Framework\Http\MimeType;
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
use App\Framework\Storage\Exceptions\StorageOperationException;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use App\Framework\Storage\ValueObjects\ObjectMetadata;
use DateInterval;
/**
* In-Memory Object Storage implementation for testing
*
* Stores objects in memory without any I/O operations.
* Structure: array<string, array<string, array{content: string, metadata: array}>>
*/
final class InMemoryObjectStorage implements ObjectStorage, StreamableObjectStorage
{
/**
* Storage structure: [bucket => [key => [content, metadata, timestamp]]]
*
* @var array<string, array<string, array{content: string, metadata: array, timestamp: int}>>
*/
private array $storage = [];
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
if (! isset($this->storage[$bucket])) {
$this->storage[$bucket] = [];
}
$etag = Hash::sha256($body);
$size = FileSize::fromBytes(strlen($body));
$timestamp = Timestamp::now();
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
$contentType = null;
if (isset($opts['contentType'])) {
$contentType = MimeType::tryFrom($opts['contentType']) ?? CustomMimeType::fromString($opts['contentType']);
} else {
$contentType = MimeType::APPLICATION_OCTET_STREAM;
}
$this->storage[$bucket][$key] = [
'content' => $body,
'metadata' => $metadata->toArray(),
'timestamp' => $timestamp->toTimestamp(),
];
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: $contentType,
lastModified: $timestamp,
metadata: $metadata,
versionId: null
);
}
public function get(string $bucket, string $key): string
{
if (! isset($this->storage[$bucket][$key])) {
throw ObjectNotFoundException::for($bucket, $key);
}
return $this->storage[$bucket][$key]['content'];
}
public function stream(string $bucket, string $key)
{
// Backward compatibility: returns temporary stream
return $this->openReadStream($bucket, $key);
}
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
{
if (! is_resource($destination)) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
}
$content = $this->get($bucket, $key);
$bytesWritten = fwrite($destination, $content);
if ($bytesWritten === false) {
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream');
}
return $bytesWritten;
}
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
{
if (! is_resource($source)) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
}
$content = stream_get_contents($source);
if ($content === false) {
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream');
}
return $this->put($bucket, $key, $content, $opts);
}
public function openReadStream(string $bucket, string $key)
{
$content = $this->get($bucket, $key);
$stream = fopen('php://temp', 'r+');
if ($stream === false) {
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
}
fwrite($stream, $content);
rewind($stream);
return $stream;
}
public function head(string $bucket, string $key): ObjectInfo
{
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
if (! isset($this->storage[$bucket][$key])) {
throw ObjectNotFoundException::for($bucket, $key);
}
$entry = $this->storage[$bucket][$key];
$content = $entry['content'];
$etag = Hash::sha256($content);
$size = FileSize::fromBytes(strlen($content));
$lastModified = Timestamp::fromTimestamp($entry['timestamp']);
$metadata = ObjectMetadata::fromArray($entry['metadata']);
return new ObjectInfo(
bucket: $bucketName,
key: $objectKey,
etag: $etag,
size: $size,
contentType: MimeType::APPLICATION_OCTET_STREAM,
lastModified: $lastModified,
metadata: $metadata,
versionId: null
);
}
public function delete(string $bucket, string $key): void
{
if (! isset($this->storage[$bucket][$key])) {
// Object doesn't exist, but that's OK for delete operations
return;
}
unset($this->storage[$bucket][$key]);
// Clean up empty bucket
if (empty($this->storage[$bucket])) {
unset($this->storage[$bucket]);
}
}
public function exists(string $bucket, string $key): bool
{
return isset($this->storage[$bucket][$key]);
}
public function url(string $bucket, string $key): ?string
{
// In-memory storage doesn't have URLs
return null;
}
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
{
// In-memory storage doesn't support presigned URLs
throw StorageOperationException::for(
'temporaryUrl',
$bucket,
$key,
'Temporary URLs are not supported for in-memory storage'
);
}
/**
* Clear all stored objects (for testing)
*/
public function clear(): void
{
$this->storage = [];
}
/**
* Get all buckets (for testing)
*
* @return array<string>
*/
public function listBuckets(): array
{
return array_keys($this->storage);
}
/**
* List all keys in a bucket (for testing)
*
* @return array<string>
*/
public function listKeys(string $bucket): array
{
if (! isset($this->storage[$bucket])) {
return [];
}
return array_keys($this->storage[$bucket]);
}
}

View File

@@ -1,17 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\CustomMimeType;
use App\Framework\Http\MimeType;
use App\Framework\Http\MimeTypeInterface;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use App\Framework\Storage\ValueObjects\ObjectMetadata;
use App\Framework\Storage\ValueObjects\VersionId;
/**
* Object Information Value Object
*
* Contains metadata about a stored object (bucket, key, size, content type, etc.)
* Uses Value Objects for type safety and validation.
*/
final readonly class ObjectInfo
{
public function __construct(
public string $bucket,
public string $key,
public ?string $etag = null, // z.B. sha256
public ?int $size = null,
public ?string $contentType = null,
public array $metadata = [], // frei (owner, width, height, …)
public ?string $versionId = null // S3: echt, FS: emuliert/optional
) {}
public BucketName $bucket,
public ObjectKey $key,
public ?Hash $etag = null,
public ?FileSize $size = null,
public ?MimeTypeInterface $contentType = null,
public ?Timestamp $lastModified = null,
public ObjectMetadata $metadata = new ObjectMetadata(),
public ?VersionId $versionId = null
) {
}
/**
* Create ObjectInfo from legacy format (for backward compatibility)
*
* @param array<string, mixed> $legacyMetadata
*/
public static function fromLegacy(
string $bucket,
string $key,
?string $etag = null,
?int $size = null,
?string $contentType = null,
array $legacyMetadata = [],
?string $versionId = null,
?int $lastModified = null
): self {
$bucketName = BucketName::fromString($bucket);
$objectKey = ObjectKey::fromString($key);
$hash = $etag !== null ? Hash::fromString($etag, HashAlgorithm::SHA256) : null;
$fileSize = $size !== null ? FileSize::fromBytes($size) : null;
$mimeType = null;
if ($contentType !== null) {
$mimeType = MimeType::tryFrom($contentType) ?? CustomMimeType::fromString($contentType);
}
$timestamp = $lastModified !== null ? Timestamp::fromTimestamp($lastModified) : null;
$metadata = ObjectMetadata::fromArray($legacyMetadata);
$versionIdObj = $versionId !== null ? VersionId::fromString($versionId) : null;
return new self(
bucket: $bucketName,
key: $objectKey,
etag: $hash,
size: $fileSize,
contentType: $mimeType,
lastModified: $timestamp,
metadata: $metadata,
versionId: $versionIdObj
);
}
/**
* Get bucket name as string
*/
public function getBucketName(): string
{
return $this->bucket->toString();
}
/**
* Get object key as string
*/
public function getKey(): string
{
return $this->key->toString();
}
/**
* Get ETag as string (if available)
*/
public function getEtag(): ?string
{
return $this->etag?->toString();
}
/**
* Get size in bytes (if available)
*/
public function getSizeBytes(): ?int
{
return $this->size?->toBytes();
}
/**
* Get content type as string (if available)
*/
public function getContentType(): ?string
{
return $this->contentType?->getValue();
}
/**
* Get last modified timestamp (if available)
*/
public function getLastModifiedTimestamp(): ?int
{
return $this->lastModified?->toTimestamp();
}
/**
* Get metadata as array
*
* @return array<string, mixed>
*/
public function getMetadataArray(): array
{
return $this->metadata->toArray();
}
/**
* Get version ID as string (if available)
*/
public function getVersionId(): ?string
{
return $this->versionId?->toString();
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
use App\Framework\Config\Environment;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Encryption\HmacService;
use App\Framework\Filesystem\Storage;
use App\Framework\HttpClient\CurlHttpClient;
use App\Framework\Random\RandomGenerator;
use App\Infrastructure\Storage\MinIoClient;
use App\Infrastructure\Storage\S3ObjectStorage;
/**
* Storage System Initializer
*
* Registriert alle Object Storage Komponenten im DI-Container
* entsprechend der Framework-Konventionen.
*/
final readonly class StorageInitializer
{
#[Initializer]
public function initializeStorage(Container $container): void
{
$env = $container->get(Environment::class);
$driver = $env->getString('STORAGE_DRIVER', 'filesystem');
// Register HmacService if not already registered
if (! $container->has(HmacService::class)) {
$container->singleton(HmacService::class, function () {
return new HmacService();
});
}
// Register MinIoClient (if needed for S3 driver)
$container->singleton(MinIoClient::class, function (Container $container) use ($env) {
return new MinIoClient(
endpoint: $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
accessKey: $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
secretKey: $env->getString('MINIO_SECRET_KEY', 'minioadmin'),
region: $env->getString('MINIO_REGION', 'us-east-1'),
usePathStyle: $env->getBool('MINIO_USE_PATH_STYLE', true),
randomGenerator: $container->get(RandomGenerator::class),
hmacService: $container->get(HmacService::class),
httpClient: $container->get(CurlHttpClient::class)
);
});
// Register S3ObjectStorage
$container->singleton(S3ObjectStorage::class, function (Container $container) {
return new S3ObjectStorage(
client: $container->get(MinIoClient::class)
);
});
// Register FilesystemObjectStorage
$container->singleton(FilesystemObjectStorage::class, function (Container $container) use ($env) {
$storage = $container->get(Storage::class);
$basePath = $env->getString('STORAGE_LOCAL_ROOT', '/var/www/html/storage/objects');
return new FilesystemObjectStorage(
storage: $storage,
basePath: $basePath
);
});
// Register default ObjectStorage based on driver
$container->singleton(ObjectStorage::class, function (Container $container) use ($driver) {
return match ($driver) {
'minio', 's3' => $container->get(S3ObjectStorage::class),
'filesystem', 'local' => $container->get(FilesystemObjectStorage::class),
'memory', 'inmemory' => $container->get(InMemoryObjectStorage::class),
default => throw new \InvalidArgumentException("Unknown storage driver: {$driver}")
};
});
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage;
/**
* Interface für Stream-basierte Object Storage Operationen
*
* Ermöglicht speichereffizientes Arbeiten mit großen Objekten ohne komplettes Laden in den Speicher.
* Ideal für große Dateien, Media-Files und Analytics-Daten.
*
* Implementiert das Interface Segregation Principle - nur Adapter die Streaming unterstützen
* implementieren dieses Interface.
*/
interface StreamableObjectStorage
{
/**
* Streamt Objekt-Inhalt direkt in einen schreibbaren Stream
*
* @param string $bucket Bucket-Name
* @param string $key Object-Key
* @param resource $destination Schreibbarer Stream (z.B. fopen('file.txt', 'w'))
* @param array<string, mixed> $opts Optionale Parameter:
* - 'bufferSize' => int (default: 8192) Buffer-Größe für Chunked Transfer
* @return int Anzahl geschriebener Bytes
* @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
*/
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int;
/**
* Lädt Objekt-Inhalt von einem lesbaren Stream
*
* @param string $bucket Bucket-Name
* @param string $key Object-Key
* @param resource $source Lesbarer Stream (z.B. fopen('file.txt', 'r'))
* @param array<string, mixed> $opts Optionale Parameter:
* - 'contentType' => string MIME-Type
* - 'metadata' => array<string, mixed> Metadata
* - 'bufferSize' => int (default: 8192) Buffer-Größe für Chunked Transfer
* @return ObjectInfo Metadaten des hochgeladenen Objekts
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
*/
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo;
/**
* Öffnet einen Read-Stream für das Objekt
*
* WICHTIG: Der zurückgegebene Stream muss vom Caller mit fclose() geschlossen werden!
*
* @param string $bucket Bucket-Name
* @param string $key Object-Key
* @return resource Lesbarer Stream
* @throws \App\Framework\Storage\Exceptions\ObjectNotFoundException Wenn Objekt nicht existiert
* @throws \App\Framework\Storage\Exceptions\StorageOperationException Bei Stream-Fehlern
*/
public function openReadStream(string $bucket, string $key);
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Bucket Name Value Object
*
* Type-safe S3 bucket identifier with validation according to AWS S3 bucket naming rules.
*/
final readonly class BucketName
{
private const MIN_LENGTH = 3;
private const MAX_LENGTH = 63;
private function __construct(
private string $value
) {
$this->validate();
}
/**
* Create BucketName from string
*/
public static function fromString(string $name): self
{
return new self($name);
}
/**
* Get bucket name as string
*/
public function toString(): string
{
return $this->value;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->value;
}
/**
* Check if two bucket names are equal
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Validate bucket name according to S3 rules
*/
private function validate(): void
{
$length = strlen($this->value);
// Length check
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
throw new InvalidArgumentException(
"Bucket name must be between " . self::MIN_LENGTH . " and " . self::MAX_LENGTH . " characters (got {$length})"
);
}
// Must start and end with alphanumeric character
if (! ctype_alnum($this->value[0])) {
throw new InvalidArgumentException('Bucket name must start with a letter or number');
}
if (! ctype_alnum($this->value[$length - 1])) {
throw new InvalidArgumentException('Bucket name must end with a letter or number');
}
// Only lowercase letters, numbers, dots, and hyphens allowed
if (! preg_match('/^[a-z0-9.-]+$/', $this->value)) {
throw new InvalidArgumentException(
'Bucket name can only contain lowercase letters, numbers, dots, and hyphens'
);
}
// No consecutive dots
if (str_contains($this->value, '..')) {
throw new InvalidArgumentException('Bucket name cannot contain consecutive dots');
}
// Cannot be formatted as an IP address (e.g., 192.168.1.1)
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $this->value)) {
throw new InvalidArgumentException('Bucket name cannot be formatted as an IP address');
}
// Cannot start with "xn--" (punycode prefix)
if (str_starts_with($this->value, 'xn--')) {
throw new InvalidArgumentException('Bucket name cannot start with "xn--"');
}
// Cannot end with "-s3alias" (S3 alias suffix)
if (str_ends_with($this->value, '-s3alias')) {
throw new InvalidArgumentException('Bucket name cannot end with "-s3alias"');
}
}
/**
* Static validation method (for pre-validation without creating object)
*/
public static function isValid(string $name): bool
{
try {
new self($name);
return true;
} catch (InvalidArgumentException) {
return false;
}
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Object Key Value Object
*
* Type-safe S3 object key identifier with validation according to AWS S3 key naming rules.
* Supports path-like structures (e.g., "folder/subfolder/file.txt").
*/
final readonly class ObjectKey
{
private const MAX_LENGTH = 1024;
private function __construct(
private string $value
) {
$this->validate();
}
/**
* Create ObjectKey from string
*/
public static function fromString(string $key): self
{
return new self($key);
}
/**
* Get object key as string
*/
public function toString(): string
{
return $this->value;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->value;
}
/**
* Check if two object keys are equal
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Get directory path (without filename)
*
* Example: "folder/subfolder/file.txt" -> "folder/subfolder"
*/
public function getDirectory(): ?string
{
$lastSlash = strrpos($this->value, '/');
if ($lastSlash === false) {
return null;
}
return substr($this->value, 0, $lastSlash);
}
/**
* Get filename (last path segment)
*
* Example: "folder/subfolder/file.txt" -> "file.txt"
*/
public function getFilename(): ?string
{
$lastSlash = strrpos($this->value, '/');
if ($lastSlash === false) {
return $this->value;
}
return substr($this->value, $lastSlash + 1);
}
/**
* Get file extension (without dot)
*
* Example: "file.txt" -> "txt"
*/
public function getExtension(): ?string
{
$filename = $this->getFilename();
if ($filename === null) {
return null;
}
$lastDot = strrpos($filename, '.');
if ($lastDot === false) {
return null;
}
return substr($filename, $lastDot + 1);
}
/**
* Check if key has a directory path
*/
public function hasDirectory(): bool
{
return str_contains($this->value, '/');
}
/**
* Validate object key according to S3 rules
*/
private function validate(): void
{
$length = strlen($this->value);
// Length check
if ($length > self::MAX_LENGTH) {
throw new InvalidArgumentException(
"Object key cannot exceed " . self::MAX_LENGTH . " characters (got {$length})"
);
}
// Empty keys are allowed (for root-level objects)
if ($length === 0) {
return;
}
// Check for control characters (0x00-0x1F, 0x7F)
if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) {
throw new InvalidArgumentException('Object key cannot contain control characters');
}
// Validate UTF-8 encoding
if (! mb_check_encoding($this->value, 'UTF-8')) {
throw new InvalidArgumentException('Object key must be valid UTF-8');
}
// S3 allows most characters, but we should avoid some problematic ones
// Note: S3 actually allows most characters, but we'll be conservative
}
/**
* Static validation method (for pre-validation without creating object)
*/
public static function isValid(string $key): bool
{
try {
new self($key);
return true;
} catch (InvalidArgumentException) {
return false;
}
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Object Metadata Value Object
*
* Type-safe metadata container for storage objects.
* Provides immutable access to key-value metadata pairs.
*/
final readonly class ObjectMetadata
{
/**
* @var array<string, mixed>
*/
private array $data;
public function __construct(array $data)
{
$this->validate($data);
$this->data = $data;
}
/**
* Create ObjectMetadata from array
*
* @param array<string, mixed> $metadata
*/
public static function fromArray(array $metadata): self
{
return new self($metadata);
}
/**
* Create empty ObjectMetadata
*/
public static function empty(): self
{
return new self([]);
}
/**
* Get metadata value by key
*
* @return mixed
*/
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
/**
* Check if metadata key exists
*/
public function has(string $key): bool
{
return isset($this->data[$key]);
}
/**
* Get all metadata as array
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->data;
}
/**
* Create new instance with additional/updated metadata
*/
public function with(string $key, mixed $value): self
{
$newData = $this->data;
$newData[$key] = $value;
return new self($newData);
}
/**
* Create new instance without specified key
*/
public function without(string $key): self
{
$newData = $this->data;
unset($newData[$key]);
return new self($newData);
}
/**
* Merge with another ObjectMetadata
*/
public function merge(self $other): self
{
return new self(array_merge($this->data, $other->data));
}
/**
* Check if metadata is empty
*/
public function isEmpty(): bool
{
return empty($this->data);
}
/**
* Get count of metadata entries
*/
public function count(): int
{
return count($this->data);
}
/**
* Get all keys
*
* @return array<string>
*/
public function keys(): array
{
return array_keys($this->data);
}
/**
* Get all values
*
* @return array<mixed>
*/
public function values(): array
{
return array_values($this->data);
}
/**
* Validate metadata structure
*
* @param array<string, mixed> $data
*/
private function validate(array $data): void
{
foreach ($data as $key => $value) {
// Key validation
if (! is_string($key)) {
throw new InvalidArgumentException('Metadata keys must be strings');
}
if (empty($key)) {
throw new InvalidArgumentException('Metadata keys cannot be empty');
}
if (strlen($key) > 255) {
throw new InvalidArgumentException('Metadata keys cannot exceed 255 characters');
}
// Value validation - only allow serializable types
if (! $this->isValidValue($value)) {
throw new InvalidArgumentException(
"Metadata value for key '{$key}' must be serializable (string, int, float, bool, null, or array of these)"
);
}
}
}
/**
* Check if value is valid for metadata
*/
private function isValidValue(mixed $value): bool
{
if (is_scalar($value) || $value === null) {
return true;
}
if (is_array($value)) {
foreach ($value as $item) {
if (! $this->isValidValue($item)) {
return false;
}
}
return true;
}
return false;
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Framework\Storage\ValueObjects;
use InvalidArgumentException;
/**
* Version ID Value Object
*
* Type-safe S3 object version identifier.
* Used for S3 object versioning support.
*/
final readonly class VersionId
{
private const MAX_LENGTH = 255;
private const NULL_VERSION = 'null';
private function __construct(
private string $value
) {
$this->validate();
}
/**
* Create VersionId from string
*/
public static function fromString(string $versionId): self
{
return new self($versionId);
}
/**
* Create null version (represents current version or no versioning)
*/
public static function null(): self
{
return new self(self::NULL_VERSION);
}
/**
* Get version ID as string
*/
public function toString(): string
{
return $this->value;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->value;
}
/**
* Check if two version IDs are equal
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Check if this is a null version
*/
public function isNullVersion(): bool
{
return $this->value === self::NULL_VERSION;
}
/**
* Validate version ID
*/
private function validate(): void
{
$length = strlen($this->value);
// Cannot be empty
if ($length === 0) {
throw new InvalidArgumentException('Version ID cannot be empty');
}
// Length check
if ($length > self::MAX_LENGTH) {
throw new InvalidArgumentException(
"Version ID cannot exceed " . self::MAX_LENGTH . " characters (got {$length})"
);
}
// S3 version IDs are typically alphanumeric with some special characters
// We'll be permissive but validate basic constraints
if (preg_match('/[\x00-\x1F\x7F]/', $this->value)) {
throw new InvalidArgumentException('Version ID cannot contain control characters');
}
}
/**
* Static validation method (for pre-validation without creating object)
*/
public static function isValid(string $versionId): bool
{
try {
new self($versionId);
return true;
} catch (InvalidArgumentException) {
return false;
}
}
}

View File

@@ -34,8 +34,15 @@ final readonly class DiscoveryTokenizer
$name = $this->findNextIdentifier($tokens, $token);
if ($name) {
// Validate: Only extract classes that are declared in the current namespace context
// The currentNamespace should match the file's namespace declaration
$currentNamespace = $context->currentNamespace ?? null;
// For class FQN, only use namespace + name (not currentClass)
$fqn = $context->currentNamespace ? $context->currentNamespace . '\\' . $name : $name;
// IMPORTANT: Only use currentNamespace if it's actually set (from namespace declaration)
// This ensures we only extract classes that are declared in this file's namespace
$fqn = $currentNamespace ? $currentNamespace . '\\' . $name : $name;
$classes[] = [
'type' => match($token->id) {
T_CLASS => 'class',
@@ -45,7 +52,7 @@ final readonly class DiscoveryTokenizer
default => 'unknown'
},
'name' => $name,
'namespace' => $context->currentNamespace,
'namespace' => $currentNamespace,
'fqn' => $fqn,
'line' => $token->line,
];
@@ -140,20 +147,58 @@ final readonly class DiscoveryTokenizer
}
/**
* Find next identifier after a token
* Find next identifier after a token (class/interface/trait/enum name)
* Stops at structural elements to avoid extracting method names
*/
private function findNextIdentifier(TokenCollection $tokens, $startToken): ?string
{
$found = false;
foreach ($tokens as $token) {
if ($found && $token->id === T_STRING) {
return $token->value;
}
$tokensArray = $tokens->toArray();
$startIndex = null;
// Find the start token index
foreach ($tokensArray as $index => $token) {
if ($token === $startToken) {
$found = true;
$startIndex = $index;
break;
}
}
if ($startIndex === null) {
return null;
}
// Tokens that are allowed before the class name (modifiers)
$allowedModifiers = [T_FINAL, T_ABSTRACT, T_READONLY];
// Tokens that stop the search (structural boundaries)
$stopTokens = [
'{', ';', // Class body start or statement end
T_EXTENDS, T_IMPLEMENTS, // Inheritance keywords
T_FUNCTION, T_FN, // Function/method declarations
T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, // Other class-like declarations
];
// Iterate through tokens after the start token
for ($i = $startIndex + 1; $i < count($tokensArray); $i++) {
$token = $tokensArray[$i];
// Stop at structural boundaries
if ($token->value === '{' || $token->value === ';' ||
$token->is($stopTokens)) {
break;
}
// Skip whitespace and allowed modifiers
if ($token->id === T_WHITESPACE || $token->is($allowedModifiers)) {
continue;
}
// Found the identifier (class name)
if ($token->id === T_STRING) {
return $token->value;
}
}
return null;
}