diff --git a/CHANGELOG.md b/CHANGELOG.md index 76515c7a..c41f41b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.6...develop) +### Added +- Discovery lives in its own package, [`pollora/discovery`](https://github.com/Pollora/discovery), which the framework requires: the engine, its contracts, `DiscoveryManager`, the cache and the `#[SkipDiscovery]` attribute, with their tests and benchmarks. Class names do not change. The framework keeps what needs Laravel: the service provider, `discovery:run` / `discovery:clear`, `DiscoveryRegistrar`, `ServiceProviderDiscovery`, the Laravel adapters, and `HasConfiguringSupport`, which builds post types and taxonomies +- Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache + +### Changed +- `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change +- One `DiscoveryCacheManager` is shared by the engine and the `pollora:doctor` discovery cache check, which built its own +- The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load + +### Deprecated +- `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` + ## [v13.35.6](https://github.com/Pollora/framework/compare/v13.35.5...v13.35.6) - 2026-10-12 ### Fixed diff --git a/composer.json b/composer.json index c7662e94..a5f9f69b 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "illuminate/console": "^13.35", "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", + "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", "pollora/hook": "^1.5", "pollora/option": "^1.1", diff --git a/src/Attributes/SkipDiscovery.php b/src/Attributes/SkipDiscovery.php deleted file mode 100644 index fcb5704f..00000000 --- a/src/Attributes/SkipDiscovery.php +++ /dev/null @@ -1,31 +0,0 @@ - $except Discovery classes that should still process this class - */ -#[Attribute(Attribute::TARGET_CLASS)] -final readonly class SkipDiscovery -{ - /** - * @param array $except Discovery classes that should still process this class - */ - public function __construct(public array $except = []) {} -} diff --git a/src/Discovery/Application/Services/DiscoveryManager.php b/src/Discovery/Application/Services/DiscoveryManager.php deleted file mode 100644 index c3fd48ab..00000000 --- a/src/Discovery/Application/Services/DiscoveryManager.php +++ /dev/null @@ -1,319 +0,0 @@ -engine->addLocation($location); - - return $this; - } - - /** - * Add multiple discovery locations - * - * @param array $paths Array of filesystem paths - * @return static Returns self for method chaining - */ - public function addLocations(array $paths): static - { - foreach ($paths as $path) { - $this->addLocation($path); - } - - return $this; - } - - /** - * Register a discovery class - * - * @param string $identifier Unique identifier for the discovery - * @param string|DiscoveryInterface $discovery Discovery class name or instance - * @return static Returns self for method chaining - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static - { - $this->engine->addDiscovery($identifier, $discovery); - - return $this; - } - - /** - * Register multiple discovery classes - * - * @param array $discoveries Map of identifier to discovery - * @return static Returns self for method chaining - */ - public function addDiscoveries(array $discoveries): static - { - $this->engine->addDiscoveries($discoveries); - - return $this; - } - - /** - * Execute discovery and apply all discoveries - * - * This is the main method that runs the entire discovery process. - * - * @return static Returns self for method chaining - */ - public function run(): static - { - $this->engine->run(); - - return $this; - } - - /** - * Execute only the discovery phase - * - * Useful for testing or when you want to inspect discovered items - * before applying them. - * - * @return static Returns self for method chaining - */ - public function discover(): static - { - $this->engine->discover(); - - return $this; - } - - /** - * Execute only the application phase - * - * Applies previously discovered items. Should be called after discover(). - * - * @return static Returns self for method chaining - */ - public function apply(): static - { - $this->engine->apply(); - - return $this; - } - - /** - * Get a specific discovery by identifier - * - * @param string $identifier The discovery identifier - * @return DiscoveryInterface The discovery instance - */ - public function getDiscovery(string $identifier): DiscoveryInterface - { - return $this->engine->getDiscovery($identifier); - } - - /** - * Get all registered discoveries - * - * @return Collection Collection of discoveries keyed by identifier - */ - public function getDiscoveries(): Collection - { - return $this->engine->getDiscoveries(); - } - - /** - * Get all discovery locations - * - * @return Collection Collection of discovery locations - */ - public function getLocations(): Collection - { - return $this->engine->getLocations(); - } - - /** - * Clear persistent discovery cache - * - * Only clears persistent cache (Spatie structure discoverer cache). - * In-memory caches are automatically cleared at process end. - * - * @return static Returns self for method chaining - */ - public function clearCache(): static - { - $this->engine->clearCache(); - - return $this; - } - - /** - * Check if a discovery is registered - * - * @param string $identifier The discovery identifier to check - * @return bool True if the discovery is registered, false otherwise - */ - public function hasDiscovery(string $identifier): bool - { - try { - $this->getDiscovery($identifier); - - return true; - } catch (\Throwable) { - return false; - } - } - - /** - * Get discovered items for a specific discovery - * - * @param string $identifier The discovery identifier - * @return array The discovered items - */ - public function getDiscoveredItems(string $identifier): array - { - $discovery = $this->getDiscovery($identifier); - - return $discovery->getItems()->all(); - } - - /** - * Run a specific discovery type on a specific location - * - * @param string $identifier The discovery identifier - * @param DiscoveryLocationInterface $location The location to discover in - * @return static Returns self for method chaining - */ - public function runSpecificDiscovery(string $identifier, DiscoveryLocationInterface $location): static - { - if (! $this->hasDiscovery($identifier)) { - return $this; - } - - // Create a temporary engine with just this discovery and location - $tempEngine = clone $this->engine; - $tempEngine->clearLocations(); - $tempEngine->addLocation($location); - - // Run only this specific discovery - $discovery = $this->getDiscovery($identifier); - $tempEngine->runDiscovery($identifier, $discovery); - - return $this; - } - - /** - * Discover all structure types in a location - * - * @param DiscoveryLocationInterface $location The location to discover in - * @return array Results grouped by discovery type - */ - public function discoverAllInLocation(DiscoveryLocationInterface $location): array - { - $results = []; - - // Create a temporary engine for this location - $tempEngine = clone $this->engine; - $tempEngine->clearLocations(); - $tempEngine->addLocation($location); - - // Run discovery and collect results by type - $tempEngine->discover(); - - foreach ($this->getDiscoveries() as $identifier => $discovery) { - $items = $discovery->getItems()->all(); - if (! empty($items)) { - $results[$identifier] = $items; - } - } - - return $results; - } - - /** - * Get the underlying discovery engine - * - * @return DiscoveryEngineInterface The discovery engine instance - */ - public function getEngine(): DiscoveryEngineInterface - { - return $this->engine; - } - - /** - * Get performance statistics from the optimized engine - * - * @return array Performance statistics or empty array if not available - */ - public function getPerformanceStats(): array - { - if (method_exists($this->engine, 'getPerformanceStats')) { - return $this->engine->getPerformanceStats(); - } - - return []; - } - - /** - * Get debugging information about the discovery state - * - * @return array Debug information - */ - public function getDebugInfo(): array - { - $debugInfo = [ - 'discoveries_count' => $this->getDiscoveries()->count(), - 'locations_count' => $this->getLocations()->count(), - 'discoveries' => [], - 'locations' => [], - ]; - - // Add discovery details - foreach ($this->getDiscoveries() as $identifier => $discovery) { - $items = $this->getDiscoveredItems($identifier); - $debugInfo['discoveries'][$identifier] = [ - 'class' => $discovery::class, - 'items_count' => count($items), - 'items' => array_slice($items, 0, 3), // Show first 3 items for debugging - ]; - } - - // Add location details - foreach ($this->getLocations() as $location) { - $debugInfo['locations'][] = [ - 'path' => $location->getPath(), - 'exists' => is_dir($location->getPath()), - ]; - } - - return $debugInfo; - } -} diff --git a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php b/src/Discovery/Domain/Contracts/DiscoversPathInterface.php deleted file mode 100644 index b1bccd32..00000000 --- a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - $locations The discovery locations - * @return string The generated cache key - */ - public function generateKey(string $discoveryIdentifier, array $locations): string; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php b/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php deleted file mode 100644 index 4b875649..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php +++ /dev/null @@ -1,139 +0,0 @@ - $locations The locations to add - * @return static Returns self for method chaining - */ - public function addLocations(array $locations): static; - - /** - * Register a discovery class - * - * @param string $identifier Unique identifier for the discovery - * @param string|DiscoveryInterface $discovery Discovery class name or instance - * @return static Returns self for method chaining - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static; - - /** - * Register multiple discovery classes - * - * @param array $discoveries Map of identifier to discovery - * @return static Returns self for method chaining - */ - public function addDiscoveries(array $discoveries): static; - - /** - * Execute discovery process for all registered discoveries - * - * Runs the discovery phase for all registered discovery classes - * across all configured locations. - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When discovery fails - */ - public function discover(): static; - - /** - * Apply all discoveries - * - * Applies all discovered items by calling the apply() method - * on all registered discovery classes. - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When application fails - */ - public function apply(): static; - - /** - * Execute discovery and apply in one operation - * - * Convenience method that runs both discover() and apply(). - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When discovery or application fails - */ - public function run(): static; - - /** - * Get a specific discovery by identifier - * - * @param string $identifier The discovery identifier - * @return DiscoveryInterface The discovery instance - * - * @throws DiscoveryNotFoundException When discovery not found - */ - public function getDiscovery(string $identifier): DiscoveryInterface; - - /** - * Get all registered discoveries - * - * @return Collection Collection of discoveries keyed by identifier - */ - public function getDiscoveries(): Collection; - - /** - * Get all discovery locations - * - * @return Collection Collection of discovery locations - */ - public function getLocations(): Collection; - - /** - * Clear persistent discovery cache. - * - * @return static Returns self for method chaining - */ - public function clearCache(): static; - - /** - * Clear all registered discovery locations. - * - * @return static Returns self for method chaining - */ - public function clearLocations(): static; - - /** - * Run a specific discovery by identifier. - * - * @param string $identifier The discovery identifier - * @param DiscoveryInterface $discovery The discovery instance to run - * @return static Returns self for method chaining - */ - public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryInterface.php b/src/Discovery/Domain/Contracts/DiscoveryInterface.php deleted file mode 100644 index 041d3b18..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryInterface.php +++ /dev/null @@ -1,73 +0,0 @@ - - */ -interface DiscoveryItemsInterface extends Countable, IteratorAggregate -{ - /** - * Add multiple values for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @param array $values The values to add - * @return static Returns self for method chaining - */ - public function addForLocation(DiscoveryLocationInterface $location, array $values): static; - - /** - * Get all values for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @return array The values for the location - */ - public function getForLocation(DiscoveryLocationInterface $location): array; - - /** - * Add a single value for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @param mixed $value The value to add - * @return static Returns self for method chaining - */ - public function add(DiscoveryLocationInterface $location, mixed $value): static; - - /** - * Check if a location has any discovered items - * - * @param DiscoveryLocationInterface $location The discovery location - * @return bool True if the location has items, false otherwise - */ - public function hasLocation(DiscoveryLocationInterface $location): bool; - - /** - * Check if any items have been loaded - * - * @return bool True if items are loaded, false if empty - */ - public function isLoaded(): bool; - - /** - * Filter to only include vendor locations - * - * Creates a new instance containing only items from vendor directories. - * - * @return static A new instance with only vendor items - */ - public function onlyVendor(): static; - - /** - * Get all items flattened into a single array - * - * @return array All discovered items - */ - public function all(): array; - - /** - * Get the items not applied yet - * - * Creates a new instance holding, for each location, the items added - * since the last call to markApplied(). - * - * @return static A new instance with only the pending items - */ - public function pending(): static; - - /** - * Mark every current item as applied - * - * Items added afterwards are the next pending() ones. - */ - public function markApplied(): void; - - /** - * Serialize the discovery items for caching. - * - * @return array - */ - public function __serialize(): array; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php b/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php deleted file mode 100644 index 7fcb8745..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php +++ /dev/null @@ -1,36 +0,0 @@ - Array of methods with the specified attributes - */ - public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array; - - /** - * Get cached class-level attributes for a class. - * - * @param string $className The fully qualified class name - * @param string|null $attributeClass Optional filter by attribute class - * @return array<\ReflectionAttribute> Array of class attributes - */ - public function getClassAttributes(string $className, ?string $attributeClass = null): array; - - /** - * Get cached public methods for a class. - * - * Eliminates redundant getMethods(IS_PUBLIC) calls when multiple - * discoveries inspect the same class. - * - * @param string $className The fully qualified class name - * @return array Array of public methods - */ - public function getPublicMethods(string $className): array; - - /** - * Get cached class instance from the DI container. - * - * @param string $className The fully qualified class name - * @return object The cached class instance - * - * @throws \Throwable If the class cannot be instantiated - */ - public function getClassInstance(string $className): object; - - /** - * Check if a class has been processed by reflection cache. - * - * @param string $className The fully qualified class name - * @return bool True if the class is cached - */ - public function hasClass(string $className): bool; - - /** - * Preload multiple classes into the cache. - * - * @param array $classNames Array of class names to preload - */ - public function preloadClasses(array $classNames): void; -} diff --git a/src/Discovery/Domain/Exceptions/DiscoveryException.php b/src/Discovery/Domain/Exceptions/DiscoveryException.php deleted file mode 100644 index 55a1f519..00000000 --- a/src/Discovery/Domain/Exceptions/DiscoveryException.php +++ /dev/null @@ -1,44 +0,0 @@ -path; - } - - /** - * Check if the directory exists - * - * @return bool True if the directory exists and is readable - */ - public function exists(): bool - { - return is_dir($this->path) && is_readable($this->path); - } - - /** - * {@inheritDoc} - */ - public function getKey(): string - { - return md5($this->path); - } - - /** - * {@inheritDoc} - */ - public function isVendor(): bool - { - return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\'); - } - - /** - * Get a string representation of the location - * - * @return string The string representation of the location - */ - public function __toString(): string - { - return $this->path; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryContext.php b/src/Discovery/Domain/Models/DiscoveryContext.php deleted file mode 100644 index 4bab9a89..00000000 --- a/src/Discovery/Domain/Models/DiscoveryContext.php +++ /dev/null @@ -1,252 +0,0 @@ -> - */ - private array $processedClasses = []; - - /** - * Shared metadata between discoveries - * - * @var array> - */ - private array $sharedData = []; - - /** - * Current processing stats - * - * @var array - */ - private array $processingStats = [ - 'classes_processed' => 0, - 'discoveries_executed' => 0, - 'cache_hits' => 0, - 'cache_misses' => 0, - 'errors' => 0, - ]; - - /** - * Create a new discovery context - * - * @param ReflectionCacheInterface $reflectionCache The reflection cache service - */ - public function __construct( - private readonly ReflectionCacheInterface $reflectionCache - ) {} - - /** - * Mark a class as processed by a specific discovery type. - * - * @param string $className The fully qualified class name - * @param string $discoveryType The discovery type identifier - */ - public function markProcessed(string $className, string $discoveryType): void - { - $this->processedClasses[$className][$discoveryType] = true; - } - - /** - * Check if a class has been processed by a specific discovery type. - * - * @param string $className The fully qualified class name - * @param string $discoveryType The discovery type identifier - * @return bool True if the class has been processed - */ - public function isProcessed(string $className, string $discoveryType): bool - { - return $this->processedClasses[$className][$discoveryType] ?? false; - } - - /** - * Check if a class has been processed by any discovery type. - * - * @param string $className The fully qualified class name - * @return bool True if the class has been processed by any discovery - */ - public function isClassProcessed(string $className): bool - { - return isset($this->processedClasses[$className]) && - (isset($this->processedClasses[$className]) && $this->processedClasses[$className] !== []); - } - - /** - * Get the list of discovery types that have processed a class. - * - * @param string $className The fully qualified class name - * @return array Array of discovery type identifiers - */ - public function getProcessedDiscoveries(string $className): array - { - return array_keys($this->processedClasses[$className] ?? []); - } - - /** - * Store shared data for a class that can be accessed by other discoveries. - * - * @param string $className The fully qualified class name - * @param string $key The data key - * @param mixed $value The data value - */ - public function setSharedData(string $className, string $key, mixed $value): void - { - $this->sharedData[$className][$key] = $value; - } - - /** - * Get shared data for a class. - * - * @param string $className The fully qualified class name - * @param string|null $key Optional specific key to retrieve - * @return mixed The shared data value or array of all data - */ - public function getSharedData(string $className, ?string $key = null): mixed - { - if ($key === null) { - return $this->sharedData[$className] ?? []; - } - - return $this->sharedData[$className][$key] ?? null; - } - - /** - * Check if shared data exists for a class and key. - * - * @param string $className The fully qualified class name - * @param string $key The data key - * @return bool True if the data exists - */ - public function hasSharedData(string $className, string $key): bool - { - return isset($this->sharedData[$className][$key]); - } - - /** - * Get the reflection cache instance. - */ - public function getReflectionCache(): ReflectionCacheInterface - { - return $this->reflectionCache; - } - - /** - * Preload a batch of classes into the reflection cache. - * - * @param array $structures Array of discovered structures - */ - public function preloadStructures(array $structures): void - { - $classNames = []; - - foreach ($structures as $structure) { - if ($structure instanceof DiscoveredClass && - ! $structure->isAbstract) { - $classNames[] = $structure->namespace.'\\'.$structure->name; - } - } - - $this->reflectionCache->preloadClasses($classNames); - } - - /** - * Increment a processing statistic. - * - * @param string $stat The statistic name - * @param int $increment The increment value - */ - public function incrementStat(string $stat, int $increment = 1): void - { - $this->processingStats[$stat] = ($this->processingStats[$stat] ?? 0) + $increment; - } - - /** - * Get processing statistics. - * - * @return array Array of processing statistics - */ - public function getStats(): array - { - return $this->processingStats; - } - - /** - * Record a cache hit. - */ - public function recordCacheHit(): void - { - $this->incrementStat('cache_hits'); - } - - /** - * Record a cache miss. - */ - public function recordCacheMiss(): void - { - $this->incrementStat('cache_misses'); - } - - /** - * Record a processing error. - */ - public function recordError(): void - { - $this->incrementStat('errors'); - } - - /** - * Get cache efficiency as a percentage. - * - * @return float Cache hit ratio as percentage - */ - public function getCacheEfficiency(): float - { - $hits = $this->processingStats['cache_hits']; - $misses = $this->processingStats['cache_misses']; - $total = $hits + $misses; - - return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0; - } - - /** - * Get a summary of the discovery context state. - * - * @return array Summary information - */ - public function getSummary(): array - { - $totalClasses = count($this->processedClasses); - $totalDiscoveries = array_sum(array_map(count(...), $this->processedClasses)); - - return [ - 'total_classes' => $totalClasses, - 'total_discovery_executions' => $totalDiscoveries, - 'cache_efficiency' => $this->getCacheEfficiency(), - 'shared_data_entries' => array_sum(array_map(count(...), $this->sharedData)), - 'stats' => $this->processingStats, - ]; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryItems.php b/src/Discovery/Domain/Models/DiscoveryItems.php deleted file mode 100644 index de3a2ff7..00000000 --- a/src/Discovery/Domain/Models/DiscoveryItems.php +++ /dev/null @@ -1,192 +0,0 @@ -> $items Initial items organized by location key - */ - public function __construct( - /** - * The discovered items organized by location key - */ - private array $items = [] - ) {} - - /** - * How many items of each location have been applied, by location key - * - * @var array - */ - private array $applied = []; - - /** - * {@inheritDoc} - */ - public function addForLocation(DiscoveryLocationInterface $location, array $values): static - { - $locationKey = $location->getKey(); - $existingValues = $this->items[$locationKey] ?? []; - - $this->items[$locationKey] = [...$existingValues, ...$values]; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getForLocation(DiscoveryLocationInterface $location): array - { - return $this->items[$location->getKey()] ?? []; - } - - /** - * {@inheritDoc} - */ - public function add(DiscoveryLocationInterface $location, mixed $value): static - { - $locationKey = $location->getKey(); - $this->items[$locationKey] ??= []; - $this->items[$locationKey][] = $value; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function hasLocation(DiscoveryLocationInterface $location): bool - { - return array_key_exists($location->getKey(), $this->items); - } - - /** - * {@inheritDoc} - */ - public function isLoaded(): bool - { - return $this->items !== []; - } - - /** - * {@inheritDoc} - */ - public function onlyVendor(): static - { - $vendorItems = []; - - foreach ($this->items as $locationKey => $items) { - // We need to check if the location is vendor, but we only have the key - // This is a limitation - we'd need to store location objects or add vendor flag - // For now, we'll use a simple heuristic based on common vendor paths - if ($this->isVendorLocation()) { - $vendorItems[$locationKey] = $items; - } - } - - return new self($vendorItems); - } - - /** - * {@inheritDoc} - */ - public function all(): array - { - return $this->items === [] ? [] : array_merge(...array_values($this->items)); - } - - /** - * {@inheritDoc} - */ - public function pending(): static - { - $pending = []; - - foreach ($this->items as $locationKey => $items) { - $remaining = array_slice($items, $this->applied[$locationKey] ?? 0); - - if ($remaining !== []) { - $pending[$locationKey] = $remaining; - } - } - - return new self($pending); - } - - /** - * {@inheritDoc} - */ - public function markApplied(): void - { - $this->applied = array_map(count(...), $this->items); - } - - /** - * {@inheritDoc} - */ - public function getIterator(): Traversable - { - return new ArrayIterator($this->all()); - } - - /** - * {@inheritDoc} - */ - public function count(): int - { - return count($this->all()); - } - - /** - * Check if a location key represents a vendor location - * - * This is a heuristic approach since we only have the CRC32 key. - * In a real implementation, we might need to store more location metadata. - * - * @return bool True if likely a vendor location - */ - private function isVendorLocation(): bool - { - // This is a simplified heuristic - in practice, you might want to - // store additional metadata about locations or use a different approach - return false; - } - - /** - * Serialize the items for caching - * - * @return array> - */ - public function __serialize(): array - { - return $this->items; - } - - /** - * Unserialize the items from cache - * - * @param array> $data The serialized data - */ - public function __unserialize(array $data): void - { - $this->items = $data; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryLocation.php b/src/Discovery/Domain/Models/DiscoveryLocation.php deleted file mode 100644 index 4abb7107..00000000 --- a/src/Discovery/Domain/Models/DiscoveryLocation.php +++ /dev/null @@ -1,104 +0,0 @@ -path = $this->resolvePath($path); - } - - /** - * {@inheritDoc} - */ - public function getNamespace(): string - { - return $this->namespace; - } - - /** - * {@inheritDoc} - */ - public function getPath(): string - { - return $this->path; - } - - /** - * {@inheritDoc} - */ - public function getKey(): string - { - return (string) crc32($this->path); - } - - /** - * {@inheritDoc} - */ - public function isVendor(): bool - { - return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\'); - } - - /** - * {@inheritDoc} - */ - public function toClassName(string $filePath): string - { - // Ensure the file path is within this location - if (! str_starts_with($filePath, $this->path)) { - return ''; - } - - // Convert file path to class name - $relativePath = substr($filePath, strlen($this->path)); - $relativePath = ltrim($relativePath, '/\\'); - - // Remove .php extension - $relativePath = preg_replace('/\.php$/', '', $relativePath); - - // Convert path separators to namespace separators - $className = str_replace(['/', '\\'], '\\', $relativePath); - - // Combine with base namespace - return rtrim($this->namespace, '\\').'\\'.$className; - } - - /** - * Resolve the absolute path - * - * @param string $path The path to resolve - * @return string The resolved absolute path - */ - private function resolvePath(string $path): string - { - $resolved = realpath(rtrim($path, '\\/')); - - return $resolved !== false ? $resolved : rtrim($path, '\\/'); - } -} diff --git a/src/Discovery/Domain/Services/HasInstancePool.php b/src/Discovery/Domain/Services/HasInstancePool.php deleted file mode 100644 index 5c5e9b8d..00000000 --- a/src/Discovery/Domain/Services/HasInstancePool.php +++ /dev/null @@ -1,58 +0,0 @@ -instancePool = $instancePool; - } - - /** - * Get an instance from the pool if available, otherwise use fallback. - * - * @param string $className The class name to instantiate - * @param callable|null $fallback Optional fallback creation function - * @return object The class instance - */ - protected function getInstanceFromPool(string $className, ?callable $fallback = null): object - { - if ($this->instancePool !== null) { - return $this->instancePool->getInstance($className); - } - - if ($fallback !== null) { - return $fallback(); - } - - return resolve($className); - } - - /** - * Check if instance pool is available. - */ - protected function hasInstancePool(): bool - { - return $this->instancePool !== null; - } -} diff --git a/src/Discovery/Domain/Services/IsDiscovery.php b/src/Discovery/Domain/Services/IsDiscovery.php deleted file mode 100644 index 81c8359d..00000000 --- a/src/Discovery/Domain/Services/IsDiscovery.php +++ /dev/null @@ -1,54 +0,0 @@ -discoveryItems ??= new DiscoveryItems; - - return $this->discoveryItems; - } - - /** - * {@inheritDoc} - */ - public function setItems(DiscoveryItemsInterface $items): void - { - $this->discoveryItems = $items; - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return static::class; - } -} diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php index 4d12e9a7..a3767ced 100644 --- a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php +++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php @@ -15,6 +15,8 @@ * * Implementation of discovery cache using Laravel's cache system. * Provides persistent caching of discovery results to improve performance. + * + * @deprecated Removed in 15.0, with DiscoveryCacheInterface. */ final readonly class LaravelDiscoveryCache implements DiscoveryCacheInterface { diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php new file mode 100644 index 00000000..3ce56e9a --- /dev/null +++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php @@ -0,0 +1,73 @@ +debugDetector->isDebugMode(); + + return new DiscoveryCacheManager( + cacheDriver: $isDebugMode ? new NullDiscoverCacheDriver : $this->resolveCacheDriver(), + logger: $this->resolveLogger(), + warnOnSlowScans: $isDebugMode, + ); + } + + private function resolveCacheDriver(): DiscoverCacheDriver + { + /** @var array{driver?: class-string, store?: string|null} $cacheConfig */ + $cacheConfig = $this->container->bound('config') + ? (array) $this->container->make('config')->get('structure-discoverer.cache', []) + : []; + + $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class; + $store = $cacheConfig['store'] ?? null; + + if ($driverClass === LaravelDiscoverCacheDriver::class) { + return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); + } + + try { + return $this->container->make($driverClass); + } catch (\Throwable) { + return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); + } + } + + private function resolveLogger(): ?LoggerInterface + { + try { + return $this->container->bound(LoggerInterface::class) + ? $this->container->make(LoggerInterface::class) + : null; + } catch (\Throwable) { + // A missing logger must not keep discovery from starting. + return null; + } + } +} diff --git a/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php new file mode 100644 index 00000000..bc5e00f6 --- /dev/null +++ b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php @@ -0,0 +1,27 @@ +container->make($className); + } +} diff --git a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php index c509d705..f11ab8f5 100644 --- a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php +++ b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php @@ -4,13 +4,19 @@ namespace Pollora\Discovery\Infrastructure\Providers; +use Illuminate\Container\Container; use Illuminate\Support\ServiceProvider; use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Discovery\Domain\Models\DirectoryLocation; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; +use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver; +use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine; +use Pollora\Discovery\Infrastructure\Services\DiscoveryRegistrar; use Pollora\Discovery\Infrastructure\Services\InstancePool; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery; @@ -64,7 +70,9 @@ public function provides(): array DiscoveryEngineInterface::class, DiscoveryManager::class, ReflectionCacheInterface::class, + InstanceResolverInterface::class, InstancePool::class, + DiscoveryCacheManager::class, DiscoveryCommand::class, DiscoveryClearCommand::class, ]; @@ -75,11 +83,24 @@ public function provides(): array */ private function registerOptimizedServices(): void { + // Discovery builds classes through Laravel's container + $this->app->singleton(InstanceResolverInterface::class, fn (Container $app): LaravelInstanceResolver => new LaravelInstanceResolver($app)); + // Register reflection cache as singleton for maximum efficiency - $this->app->singleton(ReflectionCacheInterface::class, ReflectionCache::class); + $this->app->singleton(ReflectionCacheInterface::class, fn (Container $app): ReflectionCache => new ReflectionCache( + $app->make(InstanceResolverInterface::class) + )); // Register instance pool as singleton to maintain state across discoveries - $this->app->singleton(InstancePool::class); + $this->app->singleton(InstancePool::class, fn (Container $app): InstancePool => new InstancePool( + $app->make(InstanceResolverInterface::class) + )); + + // One cache manager, shared by the engine and the doctor's cache check + $this->app->singleton(DiscoveryCacheManager::class, fn (Container $app): DiscoveryCacheManager => (new LaravelDiscoveryCacheManagerFactory( + $app, + $app->make(DebugDetectorInterface::class) + ))->make()); // Register ServiceProviderDiscovery so the DiscoveryRegistrar can find it $this->app->singleton(ServiceProviderDiscovery::class); @@ -90,11 +111,12 @@ private function registerOptimizedServices(): void */ private function registerDiscoveryEngine(): void { - $this->app->singleton(DiscoveryEngineInterface::class, fn ($app): DiscoveryEngine => new DiscoveryEngine( - container: $app, - debugDetector: $app->make(DebugDetectorInterface::class), + $this->app->singleton(DiscoveryEngineInterface::class, fn (Container $app): DiscoveryEngine => new DiscoveryEngine( + resolver: $app->make(InstanceResolverInterface::class), reflectionCache: $app->make(ReflectionCacheInterface::class), instancePool: $app->make(InstancePool::class), + cacheManager: $app->make(DiscoveryCacheManager::class), + registrar: new DiscoveryRegistrar($app), skipClasses: (array) config('discovery.skip_classes', []), skipPaths: (array) config('discovery.skip_paths', []), )); @@ -105,7 +127,7 @@ private function registerDiscoveryEngine(): void */ private function registerDiscoveryManager(): void { - $this->app->singleton(DiscoveryManager::class, fn ($app): DiscoveryManager => new DiscoveryManager( + $this->app->singleton(DiscoveryManager::class, fn (Container $app): DiscoveryManager => new DiscoveryManager( engine: $app->make(DiscoveryEngineInterface::class) )); } diff --git a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php b/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php deleted file mode 100644 index d1e98790..00000000 --- a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php +++ /dev/null @@ -1,284 +0,0 @@ - - */ - private static array $structuresCache = []; - - /** - * Above this, one location's scan is reported rather than absorbed. - */ - private const int SLOW_SCAN_MS = 250; - - /** - * Resolved cache driver for Spatie's structure discovery. - */ - private readonly ?DiscoverCacheDriver $cacheDriver; - - /** - * Where each location's structures came from in this process, in order. - * - * @var list - */ - private array $scans = []; - - public function __construct( - private readonly Container $container, - private readonly DebugDetectorInterface $debugDetector - ) { - $this->cacheDriver = $this->resolveCacheDriver(); - } - - /** - * Get structures for a specific location, using static and Spatie cache. - * - * @param DiscoveryLocationInterface $location The discovery location - * @param DiscoveryContext $context Context for recording cache stats - * @return array The discovered structures - */ - public function getStructuresForLocation(DiscoveryLocationInterface $location, DiscoveryContext $context): array - { - $cacheId = $this->generateCacheId($location); - - if (isset(self::$structuresCache[$cacheId])) { - $context->recordCacheHit(); - $this->recordScan($location, 'memory', 0.0, count(self::$structuresCache[$cacheId])); - - return self::$structuresCache[$cacheId]; - } - - $context->recordCacheMiss(); - - // Asked before reading: once the discoverer has run, a cold cache has - // been filled and looks exactly like a warm one. - $fromCache = $this->shouldUseCache() && $this->cacheDriver->has($cacheId); - - $discover = $this->createSpatieDiscoverer($location, $cacheId); - - $startedAt = microtime(true); - $structures = $discover->get(); - $elapsed = (microtime(true) - $startedAt) * 1000; - - self::$structuresCache[$cacheId] = $structures; - $this->recordScan($location, $fromCache ? 'cache' : 'disk', $elapsed, count($structures)); - - $this->warnIfSlow($location, $elapsed, count($structures)); - - return $structures; - } - - /** - * Where each location's structures came from, and what it cost. - * - * `memory` is this process's own copy, `cache` the persistent cache, `disk` - * a real scan — the one that costs, and the only one in debug mode, where - * the persistent cache is off. The context's hit and miss counters only - * know about the first of the three. - * - * @return list - */ - public function scans(): array - { - return $this->scans; - } - - /** - * @param 'memory'|'cache'|'disk' $source - */ - private function recordScan(DiscoveryLocationInterface $location, string $source, float $milliseconds, int $structures): void - { - $this->scans[] = [ - 'path' => $location->getPath(), - 'source' => $source, - 'milliseconds' => round($milliseconds, 2), - 'structures' => $structures, - ]; - } - - /** - * Say so when one location costs more than it can possibly be worth. - * - * Discovery walks a directory in full, and Symfony's Finder enumerates - * before it filters by extension — so a `node_modules/` anywhere under a - * location is paid for on every request that misses the cache, which in - * debug mode is every request. Measured once: 69,741 files walked to reach - * three PHP files, 1,691 ms against 1 ms, and nothing anywhere said so. - * - * The threshold is deliberately far above a healthy scan — the same site's - * other four locations came in between 2 and 25 ms — so this stays quiet - * until something is actually wrong. Debug only: the cache carries the - * cost in production, and a log line per request is its own problem. - */ - private function warnIfSlow(DiscoveryLocationInterface $location, float $elapsed, int $structures): void - { - if ($elapsed < self::SLOW_SCAN_MS || ! $this->debugDetector->isDebugMode()) { - return; - } - - try { - $this->container->make(LoggerInterface::class)->warning(sprintf( - 'Pollora discovery scanned %s in %dms for %d structure(s). ' - .'A directory this slow usually holds node_modules, vendor or a build output; ' - .'discovery walks every file in it on each request while the cache is off.', - $location->getPath(), - (int) round($elapsed), - $structures - )); - } catch (\Throwable) { - // A missing logger must not turn a warning into a failure. - } - } - - /** - * Clear persistent Spatie cache for the given locations. - * - * @param iterable $locations - */ - public function clearCache(iterable $locations): void - { - if (! $this->cacheDriver instanceof DiscoverCacheDriver) { - return; - } - - foreach ($locations as $location) { - $cacheId = $this->generateCacheId($location); - $this->cacheDriver->forget($cacheId); - } - } - - /** - * Classes on disk that the cached entry for a location does not list. - * - * They stay invisible to discovery — no hook, no post type, no route — until - * the cache is cleared, and nothing says so. Null when the location has no - * cached entry, or the cache is off, since then discovery reads the disk. - * - * @return list|null - */ - public function classesMissingFromCache(DiscoveryLocationInterface $location): ?array - { - if (! $this->shouldUseCache()) { - return null; - } - - $cacheId = $this->generateCacheId($location); - - if (! $this->cacheDriver->has($cacheId)) { - return null; - } - - $names = static fn (array $structures): array => array_map( - static fn (mixed $structure): string => is_object($structure) && method_exists($structure, 'getFcqn') ? $structure->getFcqn() : (string) $structure, - $structures, - ); - - return array_values(array_diff( - $names(Discover::in($location->getPath())->full()->get()), - $names($this->cacheDriver->get($cacheId)), - )); - } - - /** - * Whether discovery reads through a persistent cache (it does not in debug mode). - */ - public function isCacheEnabled(): bool - { - return $this->shouldUseCache(); - } - - /** - * Get the cache driver instance. - */ - public function getCacheDriver(): ?DiscoverCacheDriver - { - return $this->cacheDriver; - } - - /** - * Get the number of statically cached entries. - */ - public function getStaticCacheSize(): int - { - return count(self::$structuresCache); - } - - /** - * Resolve the cache driver from Laravel configuration. - */ - private function resolveCacheDriver(): ?DiscoverCacheDriver - { - if ($this->debugDetector->isDebugMode()) { - return new NullDiscoverCacheDriver; - } - - $cacheConfig = config('structure-discoverer.cache', []); - $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class; - $store = $cacheConfig['store'] ?? null; - - if ($driverClass === LaravelDiscoverCacheDriver::class) { - return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); - } - - try { - return $this->container->make($driverClass); - } catch (\Throwable) { - return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); - } - } - - /** - * Create a configured Spatie discoverer instance. - */ - private function createSpatieDiscoverer(DiscoveryLocationInterface $location, string $cacheId): Discover - { - $discover = Discover::in($location->getPath())->full(); - - if ($this->shouldUseCache()) { - return $discover->withCache($cacheId, $this->cacheDriver); - } - - return $discover; - } - - /** - * Determine if caching should be used. - */ - private function shouldUseCache(): bool - { - return $this->cacheDriver instanceof DiscoverCacheDriver - && ! ($this->cacheDriver instanceof NullDiscoverCacheDriver); - } - - /** - * Generate cache ID for a discovery location. - */ - private function generateCacheId(DiscoveryLocationInterface $location): string - { - return 'discovery_'.md5($location->getPath()); - } -} diff --git a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php b/src/Discovery/Infrastructure/Services/DiscoveryEngine.php deleted file mode 100644 index 6671206e..00000000 --- a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php +++ /dev/null @@ -1,645 +0,0 @@ - - */ - private Collection $locations; - - /** - * Collection of registered discoveries - * - * @var Collection - */ - private Collection $discoveries; - - /** - * Discovery context for coordinating across discoveries - */ - private readonly DiscoveryContext $context; - - /** - * Instance pool for managing class instances - */ - private readonly InstancePool $instancePool; - - /** - * Cache manager for Spatie's structure discovery - */ - private readonly DiscoveryCacheManager $cacheManager; - - /** - * Registrar for auto-discovering Discovery classes - */ - private readonly DiscoveryRegistrar $registrar; - - /** - * Create a new discovery engine - * - * @param Container $container The service container for dependency injection - * @param DebugDetectorInterface $debugDetector Debug mode detector - * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache - * @param InstancePool|null $instancePool Optional instance pool - * @param LoggerInterface|null $logger Optional PSR-3 logger - * @param DiscoveryCacheManager|null $cacheManager Optional cache manager - * @param array $skipClasses Classes to exclude from discovery - * @param array $skipPaths Path patterns to exclude from discovery - */ - public function __construct( - private readonly Container $container, - DebugDetectorInterface $debugDetector, - ?ReflectionCacheInterface $reflectionCache = null, - ?InstancePool $instancePool = null, - private readonly ?LoggerInterface $logger = null, - ?DiscoveryCacheManager $cacheManager = null, - private readonly array $skipClasses = [], - private readonly array $skipPaths = [], - ) { - $this->locations = new Collection; - $this->discoveries = new Collection; - - // Initialize optimized services - $reflectionCache ??= new ReflectionCache($container); - $this->instancePool = $instancePool ?? new InstancePool($container); - $this->context = new DiscoveryContext($reflectionCache); - $this->cacheManager = $cacheManager ?? new DiscoveryCacheManager($container, $debugDetector); - $this->registrar = new DiscoveryRegistrar($container, $this->logger); - } - - /** - * {@inheritDoc} - */ - public function addLocation(DiscoveryLocationInterface $location): static - { - // Check for duplicate locations (same path) - $locationPath = realpath($location->getPath()) ?: $location->getPath(); - - $isDuplicate = $this->locations->contains(function (DiscoveryLocationInterface $existingLocation) use ($locationPath): bool { - $existingPath = realpath($existingLocation->getPath()) ?: $existingLocation->getPath(); - - return $existingPath === $locationPath; - }); - - if (! $isDuplicate) { - $this->locations->push($location); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addLocations(array $locations): static - { - foreach ($locations as $location) { - $this->addLocation($location); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static - { - if ($this->discoveries->has($identifier)) { - throw InvalidDiscoveryException::duplicateIdentifier($identifier); - } - - $discoveryInstance = $this->resolveDiscovery($discovery); - $this->discoveries->put($identifier, $discoveryInstance); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addDiscoveries(array $discoveries): static - { - foreach ($discoveries as $identifier => $discovery) { - $this->addDiscovery($identifier, $discovery); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function discover(): static - { - // Auto-register Discovery classes from container bindings - $this->registrar->registerFromContainer($this); - - $allStructures = $this->discoverAllStructures(); - - $this->processStructuresUnified($allStructures); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function apply(): static - { - foreach ($this->discoveries as $discovery) { - try { - // Inject instance pool into discoveries that can use it - $this->injectInstancePoolIfSupported($discovery); - - $this->applyPending($discovery); - } catch (\Throwable $e) { - throw DiscoveryException::applicationFailed($discovery::class, $e); - } - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function run(): static - { - return $this->discover()->apply(); - } - - /** - * {@inheritDoc} - */ - public function getDiscovery(string $identifier): DiscoveryInterface - { - if (! $this->discoveries->has($identifier)) { - throw DiscoveryNotFoundException::withIdentifier($identifier); - } - - return $this->discoveries->get($identifier); - } - - /** - * {@inheritDoc} - */ - public function getDiscoveries(): Collection - { - return $this->discoveries; - } - - /** - * {@inheritDoc} - */ - public function getLocations(): Collection - { - return $this->locations; - } - - /** - * Discover items for a single discovery class - * - * @param DiscoveryInterface $discovery The discovery instance - * - * @throws DiscoveryException When discovery fails - */ - private function discoverSingle(DiscoveryInterface $discovery): void - { - try { - // Initialize fresh discovery items - $discovery->setItems(new DiscoveryItems); - - // Discover PHP structures using Spatie's native cache - $this->discoverStructures($discovery); - } catch (\Throwable $throwable) { - $this->logDiscoveryError($discovery::class, $throwable); - throw DiscoveryException::discoveryFailed($discovery::class, $throwable); - } - } - - /** - * Discover PHP structures using Spatie's discoverer - * - * @param DiscoveryInterface $discovery The discovery instance - */ - private function discoverStructures(DiscoveryInterface $discovery): void - { - $reflectionCache = $this->context->getReflectionCache(); - - foreach ($this->locations as $location) { - $structures = $this->cacheManager->getStructuresForLocation($location, $this->context); - - foreach ($structures as $structure) { - $discovery->discover($location, $structure, $reflectionCache); - } - } - } - - /** - * Clear all locations - */ - public function clearLocations(): static - { - $this->locations = new Collection; - - return $this; - } - - /** - * Clear persistent discovery cache - * - * Only clears persistent cache (Spatie's structure discoverer cache). - * In-memory caches (reflection, instance pool, static cache) are automatically - * cleared at the end of the PHP process and don't need manual clearing. - */ - public function clearCache(): static - { - $this->cacheManager->clearCache($this->locations); - - return $this; - } - - /** - * Run a specific discovery - * - * @param string $identifier The discovery identifier - * @param DiscoveryInterface $discovery The discovery instance - */ - public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static - { - try { - $this->discoverSingle($discovery); - $this->applyPending($discovery); - } catch (\Throwable $throwable) { - $this->logDiscoveryError($discovery::class, $throwable); - throw DiscoveryException::discoveryFailed($discovery::class, $throwable); - } - - return $this; - } - - /** - * Apply only the items the discovery has not applied yet. - * - * Discoveries are shared by every engine (the main one, its clones for - * modules) and their items accumulate across locations, so the engine is - * asked to apply several times per request. Handing the discovery only its - * new items makes each item apply once, whether or not the discovery's - * apply() is idempotent. Its full item list is restored afterwards, for - * whatever reads it later (status, doctor, dashboard). - * - * @param DiscoveryInterface $discovery The discovery to apply - */ - private function applyPending(DiscoveryInterface $discovery): void - { - $items = $discovery->getItems(); - $pending = $items->pending(); - - if (! $pending->isLoaded()) { - return; - } - - $discovery->setItems($pending); - - try { - $discovery->apply(); - } finally { - $items->markApplied(); - $discovery->setItems($items); - } - } - - /** - * Clone the engine - */ - public function __clone(): void - { - $this->locations = clone $this->locations; - $this->discoveries = clone $this->discoveries; - } - - /** - * Resolve a discovery instance from class name or instance - * - * @param string|DiscoveryInterface $discovery The discovery to resolve - * @return DiscoveryInterface The resolved discovery instance - * - * @throws InvalidDiscoveryException When the discovery is invalid - */ - private function resolveDiscovery(string|DiscoveryInterface $discovery): DiscoveryInterface - { - if ($discovery instanceof DiscoveryInterface) { - return $discovery; - } - - if (! class_exists($discovery)) { - throw InvalidDiscoveryException::invalidClass($discovery, 'Class does not exist'); - } - - if (! is_subclass_of($discovery, DiscoveryInterface::class)) { - throw InvalidDiscoveryException::missingInterface($discovery, DiscoveryInterface::class); - } - - try { - return $this->container->make($discovery); - } catch (\Throwable $throwable) { - throw InvalidDiscoveryException::invalidClass($discovery, 'Cannot instantiate: '.$throwable->getMessage()); - } - } - - /** - * Discover all structures from all locations using Spatie's discoverer. - * - * @return array All discovered structures with their locations - */ - private function discoverAllStructures(): array - { - $allStructures = []; - - foreach ($this->locations as $location) { - $locationStructures = $this->cacheManager->getStructuresForLocation($location, $this->context); - - foreach ($locationStructures as $structure) { - $allStructures[] = [ - 'structure' => $structure, - 'location' => $location, - ]; - } - } - - return $allStructures; - } - - /** - * Process structures using unified approach to minimize redundant operations. - * - * @param array $structures All discovered structures with their locations - */ - private function processStructuresUnified(array $structures): void - { - // Group structures by class name for batch processing - $structuresByClass = []; - - foreach ($structures as $entry) { - $structure = $entry['structure']; - - // Concrete classes, and enums carrying attributes (#[CapabilitySet]…). - $isDiscoverable = ($structure instanceof DiscoveredClass && ! $structure->isAbstract) - || ($structure instanceof DiscoveredEnum && $structure->attributes !== []); - - if (! $isDiscoverable) { - continue; - } - - $className = $structure->namespace.'\\'.$structure->name; - - // Config-level exclusions (no reflection needed) - if ($this->isSkippedByConfig($className, $structure->file)) { - continue; - } - - // Attribute-level exclusion - $skipAttr = $this->getSkipDiscoveryAttribute($structure); - if ($skipAttr instanceof SkipDiscovery && $skipAttr->except === []) { - continue; - } - - $structuresByClass[$className] = [ - 'structure' => $structure, - 'location' => $entry['location'], - 'skip_except' => $skipAttr?->except, - ]; - } - - // Initialize discoveries with fresh items only if they don't have items yet - foreach ($this->discoveries as $discovery) { - if (! $discovery->getItems()->isLoaded()) { - $discovery->setItems(new DiscoveryItems); - } - } - - // Process each class once for all applicable discoveries - foreach ($structuresByClass as $className => $data) { - $this->processClassForAllDiscoveries( - $data['structure'], - $data['location'], - $className, - $data['skip_except'] - ); - } - - $this->context->incrementStat('classes_processed', count($structuresByClass)); - } - - /** - * Process a single class for all applicable discoveries. - * - * @param DiscoveredClass|DiscoveredEnum $structure The discovered structure - * @param DiscoveryLocationInterface $location The discovery location - * @param string $className The fully qualified class name - */ - /** - * @param array|null $skipExcept If set, only these discovery classes may process the structure - */ - private function processClassForAllDiscoveries( - DiscoveredClass|DiscoveredEnum $structure, - DiscoveryLocationInterface $location, - string $className, - ?array $skipExcept = null - ): void { - try { - $reflectionCache = $this->context->getReflectionCache(); - - foreach ($this->discoveries as $discoveryId => $discovery) { - try { - if ($this->context->isProcessed($className, $discoveryId)) { - continue; - } - - // #[SkipDiscovery(except: [...])] — only allow listed discoveries - if ($skipExcept !== null && ! in_array($discovery::class, $skipExcept, true)) { - continue; - } - - $discovery->discover($location, $structure, $reflectionCache); - - $this->context->markProcessed($className, $discoveryId); - $this->context->incrementStat('discoveries_executed'); - - } catch (\Throwable $e) { - $this->context->recordError(); - $this->logDiscoveryError(sprintf('Discovery %s for class %s', $discoveryId, $className), $e); - } - } - - } catch (\Throwable $throwable) { - $this->context->recordError(); - $this->logDiscoveryError('Failed to process class '.$className, $throwable); - } - } - - /** - * Check if a class or file is excluded by config-level skip rules. - */ - private function isSkippedByConfig(string $className, string $filePath): bool - { - if (in_array($className, $this->skipClasses, true)) { - return true; - } - - foreach ($this->skipPaths as $pattern) { - if (str_contains($filePath, $pattern)) { - return true; - } - } - - return false; - } - - /** - * Get the #[SkipDiscovery] attribute instance if present on the structure. - * - * Uses Spatie's token-parsed data to detect the attribute without reflection. - * Only loads reflection to read parameters when the attribute is found. - */ - private function getSkipDiscoveryAttribute(DiscoveredClass|DiscoveredEnum $structure): ?SkipDiscovery - { - foreach ($structure->attributes as $attribute) { - if ($attribute->class === SkipDiscovery::class) { - return $this->instantiateSkipDiscovery($structure); - } - } - - return null; - } - - /** - * Instantiate the #[SkipDiscovery] attribute to read its parameters. - */ - private function instantiateSkipDiscovery(DiscoveredClass|DiscoveredEnum $structure): SkipDiscovery - { - try { - $className = $structure->namespace.'\\'.$structure->name; - $reflection = new \ReflectionClass($className); - $attrs = $reflection->getAttributes(SkipDiscovery::class); - - if ($attrs !== []) { - return $attrs[0]->newInstance(); - } - } catch (\Throwable) { - // If reflection fails, treat as full skip - } - - return new SkipDiscovery; - } - - /** - * Get the discovery context. - */ - public function getContext(): DiscoveryContext - { - return $this->context; - } - - /** - * Get the instance pool. - */ - public function getInstancePool(): InstancePool - { - return $this->instancePool; - } - - /** - * Get performance statistics. - * - * @return array - */ - public function getPerformanceStats(): array - { - return [ - 'context' => $this->context->getSummary(), - 'instance_pool' => $this->instancePool->getStats(), - 'static_cache_size' => $this->cacheManager->getStaticCacheSize(), - ]; - } - - /** - * Inject instance pool into discoveries that support it - * - * @param DiscoveryInterface $discovery The discovery to potentially inject into - */ - private function injectInstancePoolIfSupported(DiscoveryInterface $discovery): void - { - // Check if the discovery has a method to accept the instance pool - if (method_exists($discovery, 'setInstancePool')) { - $discovery->setInstancePool($this->instancePool); - } - } - - /** - * Get the cache driver instance - */ - public function getCacheDriver(): ?DiscoverCacheDriver - { - return $this->cacheManager->getCacheDriver(); - } - - /** - * Get the cache manager instance - */ - public function getCacheManager(): DiscoveryCacheManager - { - return $this->cacheManager; - } - - /** - * Log discovery errors with consistent format - * - * @param string $context The error context - * @param \Throwable $exception The exception - */ - private function logDiscoveryError(string $context, \Throwable $exception): void - { - $this->logger?->error(sprintf('%s: %s', $context, $exception->getMessage()), [ - 'exception' => $exception, - ]); - } -} diff --git a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php index 07c0f7db..40763f2b 100644 --- a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php +++ b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php @@ -7,6 +7,7 @@ use Illuminate\Container\Container; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; +use Pollora\Discovery\Domain\Contracts\DiscoveryRegistrarInterface; use Psr\Log\LoggerInterface; /** @@ -19,7 +20,7 @@ * Each ServiceProvider still registers its Discovery as a singleton in * register(). The registrar picks them up and adds them to the engine. */ -final readonly class DiscoveryRegistrar +final readonly class DiscoveryRegistrar implements DiscoveryRegistrarInterface { public function __construct( private Container $container, @@ -30,9 +31,9 @@ public function __construct( * Scan container bindings and register any DiscoveryInterface implementations. * * @param DiscoveryEngineInterface $engine The engine to register discoveries with - * @return array Identifiers of auto-registered discoveries + * @return array Identifiers of auto-registered discoveries */ - public function registerFromContainer(DiscoveryEngineInterface $engine): array + public function registerInto(DiscoveryEngineInterface $engine): array { $registered = []; diff --git a/src/Discovery/Infrastructure/Services/InstancePool.php b/src/Discovery/Infrastructure/Services/InstancePool.php deleted file mode 100644 index de66a871..00000000 --- a/src/Discovery/Infrastructure/Services/InstancePool.php +++ /dev/null @@ -1,311 +0,0 @@ - - */ - private array $instances = []; - - /** - * Currently instantiating classes (for circular dependency detection) - * - * @var array - */ - private array $instantiating = []; - - /** - * Instance creation statistics - * - * @var array - */ - private array $stats = [ - 'hits' => 0, - 'misses' => 0, - 'circular_dependencies' => 0, - 'errors' => 0, - ]; - - /** - * DI Container for instance resolution - */ - private readonly Container $container; - - public function __construct(?Container $container = null) - { - $this->container = $container ?? app(); - } - - /** - * Get or create an instance of the specified class. - * - * @param string $className The fully qualified class name - * @return object The class instance - * - * @throws CircularDependencyException If a circular dependency is detected - * @throws \RuntimeException If the class cannot be instantiated - */ - public function getInstance(string $className): object - { - // Cache hit - return existing instance - if (isset($this->instances[$className])) { - $this->stats['hits']++; - - return $this->instances[$className]; - } - - // Cache miss - need to create instance - $this->stats['misses']++; - - // Detect circular dependencies - if (isset($this->instantiating[$className])) { - $this->stats['circular_dependencies']++; - throw new CircularDependencyException( - 'Circular dependency detected while instantiating: '.$className - ); - } - - try { - // Mark as currently instantiating - $this->instantiating[$className] = true; - - // Create instance through DI container - $instance = $this->container->make($className); - - // Store in pool - $this->instances[$className] = $instance; - - // Remove from instantiating list - unset($this->instantiating[$className]); - - return $instance; - - } catch (\Throwable $throwable) { - // Clean up instantiating state on error - unset($this->instantiating[$className]); - - $this->stats['errors']++; - - throw new \RuntimeException( - sprintf('Failed to instantiate class %s: %s', $className, $throwable->getMessage()), - $throwable->getCode(), - $throwable - ); - } - } - - /** - * Check if an instance exists in the pool. - * - * @param string $className The fully qualified class name - * @return bool True if the instance exists in the pool - */ - public function hasInstance(string $className): bool - { - return isset($this->instances[$className]); - } - - /** - * Preload multiple instances into the pool. - * - * @param array $classNames Array of class names to preload - * @return array Map of successfully preloaded instances - */ - public function preloadInstances(array $classNames): array - { - $preloadedInstances = []; - - foreach ($classNames as $className) { - try { - $preloadedInstances[$className] = $this->getInstance($className); - } catch (\Throwable) { - // Skip classes that cannot be instantiated - continue; - } - } - - return $preloadedInstances; - } - - /** - * Remove an instance from the pool. - * - * @param string $className The class name to remove - * @return bool True if the instance was removed, false if it didn't exist - */ - public function removeInstance(string $className): bool - { - if (isset($this->instances[$className])) { - unset($this->instances[$className]); - - return true; - } - - return false; - } - - /** - * Get the number of instances in the pool. - * - * @return int The number of pooled instances - */ - public function getPoolSize(): int - { - return count($this->instances); - } - - /** - * Get pool statistics. - * - * @return array Pool usage statistics - */ - public function getStats(): array - { - $total = $this->stats['hits'] + $this->stats['misses']; - $hitRatio = $total > 0 ? round(($this->stats['hits'] / $total) * 100, 2) : 0.0; - - return [ - 'pool_size' => $this->getPoolSize(), - 'cache_hits' => $this->stats['hits'], - 'cache_misses' => $this->stats['misses'], - 'hit_ratio_percent' => $hitRatio, - 'circular_dependencies' => $this->stats['circular_dependencies'], - 'instantiation_errors' => $this->stats['errors'], - 'total_requests' => $total, - ]; - } - - /** - * Get list of all pooled class names. - * - * @return array Array of pooled class names - */ - public function getPooledClasses(): array - { - return array_keys($this->instances); - } - - /** - * Perform memory optimization by removing unused instances. - * - * @param int $maxPoolSize Maximum number of instances to keep - */ - public function optimize(int $maxPoolSize = 100): void - { - if ($this->getPoolSize() <= $maxPoolSize) { - return; - } - - // Simple LRU-style cleanup - remove excess instances - $excessCount = $this->getPoolSize() - $maxPoolSize; - $classNames = array_keys($this->instances); - - for ($i = 0; $i < $excessCount; $i++) { - if (isset($classNames[$i])) { - unset($this->instances[$classNames[$i]]); - } - } - } - - /** - * Reset statistics counters. - */ - public function resetStats(): void - { - $this->stats = [ - 'hits' => 0, - 'misses' => 0, - 'circular_dependencies' => 0, - 'errors' => 0, - ]; - } - - /** - * Check if a class is currently being instantiated (circular dependency check). - * - * @param string $className The class name to check - * @return bool True if the class is currently being instantiated - */ - public function isInstantiating(string $className): bool - { - return isset($this->instantiating[$className]); - } - - /** - * Get debug information about the instance pool state. - * - * @return array Debug information - */ - public function getDebugInfo(): array - { - return [ - 'instances' => $this->getPooledClasses(), - 'instantiating' => array_keys($this->instantiating), - 'stats' => $this->getStats(), - 'memory_usage' => $this->estimateMemoryUsage(), - ]; - } - - /** - * Estimate memory usage of the instance pool. - * - * @return string Human-readable memory usage estimate - */ - private function estimateMemoryUsage(): string - { - $memoryUsage = 0; - - foreach ($this->instances as $instance) { - // Rough estimate - this is not precise but gives an idea - $memoryUsage += strlen(serialize($instance)); - } - - return $this->formatBytes($memoryUsage); - } - - /** - * Format bytes into human-readable format. - * - * @param int $bytes Number of bytes - * @return string Formatted string - */ - private function formatBytes(int $bytes): string - { - $units = ['B', 'KB', 'MB', 'GB']; - $unitIndex = 0; - - while ($bytes >= 1024 && $unitIndex < count($units) - 1) { - $bytes /= 1024; - $unitIndex++; - } - - return round($bytes, 2).' '.$units[$unitIndex]; - } -} - -/** - * Exception thrown when circular dependency is detected during instantiation - */ -class CircularDependencyException extends \RuntimeException {} diff --git a/src/Discovery/Infrastructure/Services/ReflectionCache.php b/src/Discovery/Infrastructure/Services/ReflectionCache.php deleted file mode 100644 index 6e8b3a25..00000000 --- a/src/Discovery/Infrastructure/Services/ReflectionCache.php +++ /dev/null @@ -1,255 +0,0 @@ - - */ - private array $classReflections = []; - - /** - * Cache for class-level attributes - * - * @var array>> - */ - private array $classAttributes = []; - - /** - * Cache for method-level attributes grouped by class and attribute type - * - * @var array>> - */ - private array $methodsWithAttributes = []; - - /** - * Cache for public methods per class - * - * @var array> - */ - private array $publicMethods = []; - - /** - * Cache for class instances from DI container - * - * @var array - */ - private array $classInstances = []; - - /** - * Cache for processed classes to avoid reprocessing - * - * @var array - */ - private array $processedClasses = []; - - /** - * DI Container for instance resolution - */ - private readonly Container $container; - - public function __construct(?Container $container = null) - { - $this->container = $container ?? app(); - } - - /** - * {@inheritDoc} - */ - public function getClassReflection(string $className): ReflectionClass - { - if (! isset($this->classReflections[$className])) { - try { - // Check if class exists and can be autoloaded first - if (! class_exists($className, true)) { - throw new ReflectionException(sprintf('Class %s does not exist or cannot be autoloaded', $className)); - } - - $this->classReflections[$className] = new ReflectionClass($className); - $this->processedClasses[$className] = true; - } catch (\Throwable $e) { - // Log the error but mark as processed to avoid retrying - $this->processedClasses[$className] = true; - - throw new ReflectionException( - sprintf('Failed to create reflection for class %s: %s', $className, $e->getMessage()), - $e->getCode(), - $e - ); - } - } - - return $this->classReflections[$className]; - } - - /** - * {@inheritDoc} - */ - public function getPublicMethods(string $className): array - { - $this->publicMethods[$className] ??= $this->getClassReflection($className) - ->getMethods(ReflectionMethod::IS_PUBLIC); - - return $this->publicMethods[$className]; - } - - /** - * {@inheritDoc} - */ - public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array - { - $cacheKey = $attributeClass ?? '*'; - - if (! isset($this->methodsWithAttributes[$className][$cacheKey])) { - $this->buildMethodAttributeCache($className, $attributeClass); - } - - return $this->methodsWithAttributes[$className][$cacheKey]; - } - - /** - * {@inheritDoc} - */ - public function getClassAttributes(string $className, ?string $attributeClass = null): array - { - $cacheKey = $attributeClass ?? '*'; - - if (! isset($this->classAttributes[$className][$cacheKey])) { - $this->buildClassAttributeCache($className, $attributeClass); - } - - return $this->classAttributes[$className][$cacheKey]; - } - - /** - * {@inheritDoc} - */ - public function getClassInstance(string $className): object - { - if (! isset($this->classInstances[$className])) { - try { - // Validate the class can be instantiated before attempting - $reflection = $this->getClassReflection($className); - - if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) { - throw new \InvalidArgumentException( - 'Cannot instantiate abstract class, interface, or trait: '.$className - ); - } - - if (! $reflection->isInstantiable()) { - throw new \InvalidArgumentException(sprintf('Class %s is not instantiable', $className)); - } - - $this->classInstances[$className] = $this->container->make($className); - } catch (\Throwable $e) { - throw new \RuntimeException( - sprintf('Failed to instantiate class %s: %s', $className, $e->getMessage()), - $e->getCode(), - $e - ); - } - } - - return $this->classInstances[$className]; - } - - /** - * {@inheritDoc} - */ - public function hasClass(string $className): bool - { - return isset($this->processedClasses[$className]); - } - - /** - * {@inheritDoc} - */ - public function preloadClasses(array $classNames): void - { - foreach ($classNames as $className) { - if (! $this->hasClass($className)) { - try { - $this->getClassReflection($className); - } catch (\Throwable) { - // Skip classes that cannot be loaded - continue; - } - } - } - } - - /** - * Build method attribute cache for a specific class and attribute type. - * - * @param string $className The class name to process - * @param string|null $attributeClass Optional attribute class filter - */ - private function buildMethodAttributeCache(string $className, ?string $attributeClass = null): void - { - $cacheKey = $attributeClass ?? '*'; - $methodsWithAttributes = []; - - foreach ($this->getPublicMethods($className) as $method) { - if ($attributeClass === null) { - // Get all methods with any attributes - $hasMatchingAttribute = ! empty($method->getAttributes()); - } else { - // Get methods with specific attribute class - $hasMatchingAttribute = ! empty($method->getAttributes($attributeClass)); - } - - if ($hasMatchingAttribute) { - $methodsWithAttributes[] = $method; - } - } - - $this->methodsWithAttributes[$className][$cacheKey] = $methodsWithAttributes; - } - - /** - * Build class attribute cache for a specific class and attribute type. - * - * @param string $className The class name to process - * @param string|null $attributeClass Optional attribute class filter - */ - private function buildClassAttributeCache(string $className, ?string $attributeClass = null): void - { - $cacheKey = $attributeClass ?? '*'; - $reflection = $this->getClassReflection($className); - - if ($attributeClass === null) { - // Get all class attributes - $attributes = $reflection->getAttributes(); - } else { - // Get specific class attributes - $attributes = $reflection->getAttributes($attributeClass); - } - - $this->classAttributes[$className][$cacheKey] = $attributes; - } -} diff --git a/tests/Benchmark/BenchmarkDiscovery.php b/tests/Benchmark/BenchmarkDiscovery.php deleted file mode 100644 index ab42faac..00000000 --- a/tests/Benchmark/BenchmarkDiscovery.php +++ /dev/null @@ -1,120 +0,0 @@ -items = new DiscoveryItems; - } - - public function discover( - DiscoveryLocationInterface $location, - DiscoveredStructure $structure, - ?ReflectionCacheInterface $reflectionCache = null - ): void { - if (! $structure instanceof DiscoveredClass) { - return; - } - - if ($structure->isAbstract) { - return; - } - - $className = $structure->namespace.'\\'.$structure->name; - - if (! $reflectionCache instanceof ReflectionCacheInterface) { - return; - } - - try { - $reflection = $reflectionCache->getClassReflection($className); - - // Process ALL class-level attributes (like PostTypeDiscovery does) - $classAttributes = $reflection->getAttributes(); - foreach ($classAttributes as $attribute) { - try { - $instance = $attribute->newInstance(); - $this->items->add($location, [ - 'type' => 'class_attribute', - 'class' => $className, - 'attribute' => $attribute->getName(), - 'instance' => $instance, - ]); - } catch (\Throwable) { - // Skip attributes that can't be instantiated (missing classes) - } - } - - // Process ALL public methods and their attributes (like HookDiscovery does) - $methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC); - foreach ($methods as $method) { - $methodAttributes = $method->getAttributes(); - foreach ($methodAttributes as $attribute) { - try { - $instance = $attribute->newInstance(); - $this->items->add($location, [ - 'type' => 'method_attribute', - 'class' => $className, - 'method' => $method->getName(), - 'attribute' => $attribute->getName(), - 'instance' => $instance, - 'param_count' => $method->getNumberOfParameters(), - ]); - } catch (\Throwable) { - // Skip attributes that can't be instantiated - } - } - } - } catch (\Throwable) { - // Skip unloadable classes, like real discoveries - } - } - - public function getItems(): DiscoveryItemsInterface - { - return $this->items; - } - - public function setItems(DiscoveryItemsInterface $items): void - { - $this->items = $items; - } - - public function apply(): void - { - // No-op for benchmarking — we measure discovery, not application - } - - public function getIdentifier(): string - { - return $this->identifier; - } -} diff --git a/tests/Benchmark/DiscoveryBenchmark.php b/tests/Benchmark/DiscoveryBenchmark.php deleted file mode 100644 index 603c701f..00000000 --- a/tests/Benchmark/DiscoveryBenchmark.php +++ /dev/null @@ -1,479 +0,0 @@ -> */ - private array $results = []; - - /** @var array> */ - private array $resultsMulti = []; - - public function __construct() - { - $this->container = new Container; - Container::setInstance($this->container); - - $this->debugDetector = new class implements DebugDetectorInterface - { - public function isDebugMode(): bool - { - return true; // Force no persistent cache - } - }; - - $this->fixturesBasePath = __DIR__.'/Generated'; - } - - /** - * Run the full benchmark suite. - * - * @param int[] $classCounts Class counts to benchmark - * @param int $iterations Number of iterations per benchmark for averaging - */ - public function run(array $classCounts = [50, 100, 250, 500, 1000, 2000], int $iterations = 3): void - { - $this->printHeader(); - - echo "\n ── SINGLE LOCATION (flat directory) ──\n"; - foreach ($classCounts as $count) { - $this->benchmarkClassCount($count, $iterations); - } - - $this->printSummary(); - - // Multi-location benchmark - echo "\n ── MULTI-LOCATION (modules with DDD subdirs) ──\n"; - $this->resultsMulti = []; - $multiScenarios = [ - ['classes' => 100, 'modules' => 5], - ['classes' => 250, 'modules' => 8], - ['classes' => 500, 'modules' => 10], - ['classes' => 1000, 'modules' => 15], - ]; - - foreach ($multiScenarios as $scenario) { - $this->benchmarkMultiLocation($scenario['classes'], $scenario['modules'], $iterations); - } - - $this->printMultiLocationSummary(); - } - - private function benchmarkClassCount(int $count, int $iterations): void - { - echo "\n".str_repeat('─', 70)."\n"; - echo " Benchmarking with {$count} classes ({$iterations} iterations)\n"; - echo str_repeat('─', 70)."\n"; - - // Generate fixtures - $generator = new FixtureGenerator( - $this->fixturesBasePath, - 'Tests\\Benchmark\\Generated' - ); - - $fixtureInfo = $generator->generate($count); - $this->printDistribution($fixtureInfo['distribution']); - - // Register autoloader for generated classes - $autoloader = $this->registerAutoloader($fixtureInfo['path'], $fixtureInfo['namespace']); - - $timings = [ - 'spatie_scan' => [], - 'full_discovery' => [], - 'memory_peak' => [], - 'classes_processed' => [], - 'total_items' => [], - ]; - - for ($i = 0; $i < $iterations; $i++) { - echo ' Iteration '.($i + 1).'...'; - - // Clear any static caches between iterations - $this->clearStaticCaches(); - - // Benchmark: Spatie scan phase only - $scanResult = $this->benchmarkSpatieScan($fixtureInfo); - $timings['spatie_scan'][] = $scanResult['time_ms']; - - // Clear static caches again - $this->clearStaticCaches(); - - // Benchmark: Full discovery (scan + process for all discovery types) - $fullResult = $this->benchmarkFullDiscovery($fixtureInfo); - $timings['full_discovery'][] = $fullResult['time_ms']; - $timings['memory_peak'][] = $fullResult['memory_peak_mb']; - $timings['classes_processed'][] = $fullResult['classes_processed']; - $timings['total_items'][] = $fullResult['total_items']; - - echo " done\n"; - } - - // Unregister autoloader - spl_autoload_unregister($autoloader); - - // Cleanup generated files - $generator->cleanup(); - - // Store averaged results - $this->results[$count] = [ - 'count' => $count, - 'spatie_scan_avg_ms' => $this->average($timings['spatie_scan']), - 'spatie_scan_min_ms' => min($timings['spatie_scan']), - 'spatie_scan_max_ms' => max($timings['spatie_scan']), - 'full_discovery_avg_ms' => $this->average($timings['full_discovery']), - 'full_discovery_min_ms' => min($timings['full_discovery']), - 'full_discovery_max_ms' => max($timings['full_discovery']), - 'memory_peak_mb' => $this->average($timings['memory_peak']), - 'classes_processed' => (int) $this->average($timings['classes_processed']), - 'total_items' => (int) $this->average($timings['total_items']), - 'per_class_us' => ($this->average($timings['full_discovery']) * 1000) / $count, - ]; - - $r = $this->results[$count]; - echo "\n"; - echo sprintf(" Spatie scan: avg %7.2f ms (min %.2f / max %.2f)\n", $r['spatie_scan_avg_ms'], $r['spatie_scan_min_ms'], $r['spatie_scan_max_ms']); - echo sprintf(" Full discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['full_discovery_avg_ms'], $r['full_discovery_min_ms'], $r['full_discovery_max_ms']); - echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']); - echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_peak_mb']); - echo sprintf(" Classes found: %d\n", $r['classes_processed']); - echo sprintf(" Attributes found: %d (avg %.1f/class)\n", $r['total_items'], $r['total_items'] / max(1, $r['classes_processed'])); - } - - /** - * Benchmark: Spatie structure scan only (no discovery processing). - * - * @return array{time_ms: float, structures_count: int} - */ - private function benchmarkSpatieScan(array $fixtureInfo): array - { - $cacheManager = new DiscoveryCacheManager($this->container, $this->debugDetector); - $reflectionCache = new ReflectionCache($this->container); - $context = new DiscoveryContext($reflectionCache); - - $location = new DiscoveryLocation( - $fixtureInfo['namespace'], - $fixtureInfo['path'] - ); - - $start = hrtime(true); - $structures = $cacheManager->getStructuresForLocation($location, $context); - $elapsed = (hrtime(true) - $start) / 1_000_000; - - return [ - 'time_ms' => $elapsed, - 'structures_count' => count($structures), - ]; - } - - /** - * Benchmark: Full discovery with multiple discovery types. - * - * Uses a lightweight HookDiscovery mock to test the real DiscoveryEngine - * processing pipeline without depending on WordPress runtime. - * - * @return array{time_ms: float, memory_peak_mb: float, classes_processed: int, stats: array} - */ - private function benchmarkFullDiscovery(array $fixtureInfo): array - { - $engine = new DiscoveryEngine( - $this->container, - $this->debugDetector - ); - - $location = new DiscoveryLocation( - $fixtureInfo['namespace'], - $fixtureInfo['path'] - ); - - // Add multiple discovery types to simulate realistic load - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $memBefore = memory_get_usage(true); - $start = hrtime(true); - - $engine->discover(); - - $elapsed = (hrtime(true) - $start) / 1_000_000; - $memAfter = memory_get_peak_usage(true); - - $stats = $engine->getPerformanceStats(); - - // Count total discovered items across all discoveries - $totalItems = 0; - foreach ($engine->getDiscoveries() as $discovery) { - $totalItems += count($discovery->getItems()); - } - - return [ - 'time_ms' => $elapsed, - 'memory_peak_mb' => ($memAfter - $memBefore) / (1024 * 1024), - 'classes_processed' => $stats['context']['total_classes'], - 'total_items' => $totalItems, - 'stats' => $stats, - ]; - } - - private function benchmarkMultiLocation(int $totalClasses, int $moduleCount, int $iterations): void - { - $key = sprintf('%dc_%dm', $totalClasses, $moduleCount); - echo "\n".str_repeat('─', 70)."\n"; - echo " {$totalClasses} classes across {$moduleCount} modules ({$iterations} iterations)\n"; - echo str_repeat('─', 70)."\n"; - - $generator = new FixtureGenerator( - $this->fixturesBasePath, - 'Tests\\Benchmark\\Generated' - ); - - $multiInfo = $generator->generateMultiLocation($totalClasses, $moduleCount); - echo sprintf(" Generated: %d classes, %d modules, %d subdirs\n", - $multiInfo['total_classes'], $multiInfo['modules'], $multiInfo['dirs_created']); - - // Register autoloaders for all locations - $autoloaders = []; - foreach ($multiInfo['locations'] as $loc) { - $autoloaders[] = $this->registerAutoloader($loc['path'], $loc['namespace']); - } - - $timings = ['scan' => [], 'discovery' => [], 'memory' => [], 'items' => []]; - - for ($i = 0; $i < $iterations; $i++) { - echo ' Iteration '.($i + 1).'...'; - $this->clearStaticCaches(); - - // Full discovery with multiple locations - $engine = new DiscoveryEngine($this->container, $this->debugDetector); - - foreach ($multiInfo['locations'] as $loc) { - $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); - } - - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $memBefore = memory_get_usage(true); - $start = hrtime(true); - $engine->discover(); - $elapsed = (hrtime(true) - $start) / 1_000_000; - - $totalItems = 0; - foreach ($engine->getDiscoveries() as $d) { - $totalItems += count($d->getItems()); - } - - $timings['scan'][] = $elapsed; // Combined scan + processing - $timings['memory'][] = (memory_get_peak_usage(true) - $memBefore) / (1024 * 1024); - $timings['items'][] = $totalItems; - - echo " done\n"; - } - - foreach ($autoloaders as $al) { - spl_autoload_unregister($al); - } - - $generator->cleanup(); - - $avgMs = $this->average($timings['scan']); - $this->resultsMulti[$key] = [ - 'classes' => $multiInfo['total_classes'], - 'modules' => $multiInfo['modules'], - 'dirs' => $multiInfo['dirs_created'], - 'avg_ms' => $avgMs, - 'min_ms' => min($timings['scan']), - 'max_ms' => max($timings['scan']), - 'per_class_us' => ($avgMs * 1000) / max(1, $multiInfo['total_classes']), - 'items' => (int) $this->average($timings['items']), - 'memory_mb' => $this->average($timings['memory']), - ]; - - $r = $this->resultsMulti[$key]; - echo sprintf("\n Discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['avg_ms'], $r['min_ms'], $r['max_ms']); - echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']); - echo sprintf(" Attrs found: %d\n", $r['items']); - echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_mb']); - } - - private function printMultiLocationSummary(): void - { - if ($this->resultsMulti === []) { - return; - } - - echo "\n\n"; - echo "╔════════════════════════════════════════════════════════════════════════════╗\n"; - echo "║ MULTI-LOCATION SUMMARY ║\n"; - echo "╠════════════════════════════════════════════════════════════════════════════╣\n"; - echo sprintf("║ %-7s │ %-7s │ %-5s │ %-11s │ %-9s │ %-6s │ %-6s ║\n", - 'Classes', 'Modules', 'Dirs', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB'); - echo "╠════════════════════════════════════════════════════════════════════════════╣\n"; - - foreach ($this->resultsMulti as $r) { - echo sprintf( - "║ %-7d │ %-7d │ %-5d │ %11.2f │ %6.1f us │ %6d │ %5.2f ║\n", - $r['classes'], $r['modules'], $r['dirs'], - $r['avg_ms'], $r['per_class_us'], $r['items'], $r['memory_mb'] - ); - } - - echo "╚════════════════════════════════════════════════════════════════════════════╝\n"; - - // Compare single vs multi for same class count - echo "\n Single-location vs Multi-location comparison:\n"; - foreach ($this->resultsMulti as $r) { - $classCount = $r['classes']; - // Find closest single-location result - $closest = null; - $closestDiff = PHP_INT_MAX; - foreach ($this->results as $sr) { - $diff = abs($sr['count'] - $classCount); - if ($diff < $closestDiff) { - $closestDiff = $diff; - $closest = $sr; - } - } - - if ($closest && $closestDiff < $classCount * 0.3) { - $overhead = (($r['avg_ms'] / max(0.01, $closest['full_discovery_avg_ms'])) - 1) * 100; - echo sprintf(" %d classes: single=%.1fms, multi(%d modules)=%.1fms => %+.0f%% overhead\n", - $classCount, $closest['full_discovery_avg_ms'], $r['modules'], $r['avg_ms'], $overhead); - } - } - - echo "\n"; - } - - private function registerAutoloader(string $path, string $namespace): \Closure - { - $autoloader = function (string $class) use ($path, $namespace): void { - if (! str_starts_with($class, $namespace)) { - return; - } - - $relative = str_replace($namespace.'\\', '', $class); - $file = $path.'/'.str_replace('\\', '/', $relative).'.php'; - - if (file_exists($file)) { - require_once $file; - } - }; - - spl_autoload_register($autoloader); - - return $autoloader; - } - - private function clearStaticCaches(): void - { - // Use reflection to clear DiscoveryCacheManager's static cache - $ref = new \ReflectionClass(DiscoveryCacheManager::class); - $prop = $ref->getProperty('structuresCache'); - $prop->setValue(null, []); - } - - private function average(array $values): float - { - return array_sum($values) / count($values); - } - - private function printHeader(): void - { - echo "\n"; - echo "╔══════════════════════════════════════════════════════════════════════╗\n"; - echo "║ Pollora Discovery System — Performance Benchmark ║\n"; - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - echo "║ Tests discovery WITHOUT persistent cache (debug mode). ║\n"; - echo "║ Measures: Spatie scan, full discovery pipeline, per-class cost. ║\n"; - echo "╚══════════════════════════════════════════════════════════════════════╝\n"; - echo "\n"; - echo ' PHP '.phpversion().' | Memory limit: '.ini_get('memory_limit')."\n"; - } - - private function printDistribution(array $distribution): void - { - $parts = []; - foreach ($distribution as $type => $count) { - $parts[] = sprintf('%s: %s', $type, $count); - } - - echo ' Distribution: '.implode(', ', $parts)."\n"; - } - - private function printSummary(): void - { - echo "\n\n"; - echo "╔══════════════════════════════════════════════════════════════════════╗\n"; - echo "║ SUMMARY TABLE ║\n"; - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - echo sprintf("║ %-7s │ %-10s │ %-12s │ %-9s │ %-6s │ %-6s ║\n", 'Classes', 'Scan (ms)', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB'); - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - - foreach ($this->results as $r) { - echo sprintf( - "║ %-7d │ %10.2f │ %12.2f │ %6.1f us │ %6d │ %5.2f ║\n", - $r['count'], - $r['spatie_scan_avg_ms'], - $r['full_discovery_avg_ms'], - $r['per_class_us'], - $r['total_items'], - $r['memory_peak_mb'] - ); - } - - echo "╚══════════════════════════════════════════════════════════════════════╝\n"; - - // Scaling analysis - if (count($this->results) >= 2) { - echo "\n Scaling analysis:\n"; - $keys = array_keys($this->results); - $first = $this->results[$keys[0]]; - $last = $this->results[$keys[count($keys) - 1]]; - $classRatio = $last['count'] / $first['count']; - $timeRatio = $last['full_discovery_avg_ms'] / max(0.01, $first['full_discovery_avg_ms']); - - echo sprintf(" %dx classes => %.1fx time (ideal linear = %.1fx)\n", (int) $classRatio, $timeRatio, $classRatio); - - if ($timeRatio > $classRatio * 1.5) { - echo " => Super-linear scaling detected — optimization opportunities likely\n"; - } elseif ($timeRatio <= $classRatio * 1.1) { - echo " => Linear scaling — discovery scales well\n"; - } else { - echo " => Near-linear scaling — acceptable\n"; - } - } - - echo "\n"; - } -} diff --git a/tests/Benchmark/DiscoveryPerformanceTest.php b/tests/Benchmark/DiscoveryPerformanceTest.php deleted file mode 100644 index 9eb86ff2..00000000 --- a/tests/Benchmark/DiscoveryPerformanceTest.php +++ /dev/null @@ -1,331 +0,0 @@ -getProperty('structuresCache'); - $prop->setValue(null, []); -} - -function generateAndRegister(int $count): array -{ - $basePath = __DIR__.'/Generated'; - $namespace = 'Tests\\Benchmark\\Generated'; - - $generator = new FixtureGenerator($basePath, $namespace); - $info = $generator->generate($count); - - $autoloader = function (string $class) use ($basePath, $namespace): void { - if (! str_starts_with($class, $namespace)) { - return; - } - - $relative = str_replace($namespace.'\\', '', $class); - $file = $basePath.'/'.str_replace('\\', '/', $relative).'.php'; - if (file_exists($file)) { - require_once $file; - } - }; - spl_autoload_register($autoloader); - - return ['info' => $info, 'generator' => $generator, 'autoloader' => $autoloader]; -} - -describe('Discovery Performance', function (): void { - - afterEach(function (): void { - clearDiscoveryCacheManagerStatic(); - restorePreviousContainer(); - Facade::clearResolvedInstances(); - Facade::setFacadeApplication(Container::getInstance()); - }); - - it('scans 100 classes via Spatie in under 500ms', function (): void { - $setup = generateAndRegister(100); - - try { - $container = createBenchContainer(); - $cacheManager = new DiscoveryCacheManager($container, createBenchDebugDetector()); - $reflectionCache = new ReflectionCache($container); - $context = new DiscoveryContext($reflectionCache); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $start = hrtime(true); - $structures = $cacheManager->getStructuresForLocation($location, $context); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - expect(count($structures))->toBeGreaterThanOrEqual(100); - expect($elapsedMs)->toBeLessThan(500); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('runs full discovery on 100 classes with 5 discoveries in under 2000ms', function (): void { - $setup = generateAndRegister(100); - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(100); - expect($elapsedMs)->toBeLessThan(2000); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('runs full discovery on 500 classes with 5 discoveries in under 10000ms', function (): void { - $setup = generateAndRegister(500); - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(500); - expect($elapsedMs)->toBeLessThan(10000); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('scales near-linearly from 100 to 500 classes', function (): void { - // Benchmark 100 classes - $setup100 = generateAndRegister(100); - $container = createBenchContainer(); - - try { - $engine100 = new DiscoveryEngine($container, createBenchDebugDetector()); - $loc100 = new DiscoveryLocation($setup100['info']['namespace'], $setup100['info']['path']); - $engine100->addLocation($loc100); - $engine100->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine100->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine100->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine100->discover(); - $time100 = (hrtime(true) - $start) / 1_000_000; - } finally { - spl_autoload_unregister($setup100['autoloader']); - $setup100['generator']->cleanup(); - } - - clearDiscoveryCacheManagerStatic(); - - // Benchmark 500 classes - $setup500 = generateAndRegister(500); - - try { - $engine500 = new DiscoveryEngine($container, createBenchDebugDetector()); - $loc500 = new DiscoveryLocation($setup500['info']['namespace'], $setup500['info']['path']); - $engine500->addLocation($loc500); - $engine500->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine500->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine500->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine500->discover(); - $time500 = (hrtime(true) - $start) / 1_000_000; - } finally { - spl_autoload_unregister($setup500['autoloader']); - $setup500['generator']->cleanup(); - } - - // 5x classes should be less than 8x time (allowing for overhead) - $ratio = $time500 / max(0.01, $time100); - expect($ratio)->toBeLessThan(8.0, - sprintf('Scaling ratio: %sx for 5x classes (100: %sms, 500: %sms)', $ratio, $time100, $time500) - ); - }); - - it('processes each class in under 2000 microseconds on average', function (): void { - $setup = generateAndRegister(250); - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedUs = (hrtime(true) - $start) / 1_000; - - $perClassUs = $elapsedUs / 250; - - expect($perClassUs)->toBeLessThan(3000, - sprintf('Per-class cost: %s us (total: ', $perClassUs).($elapsedUs / 1000).' ms for 250 classes)' - ); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('handles multi-location with DDD subdirectories without excessive overhead', function (): void { - $basePath = __DIR__.'/Generated'; - $namespace = 'Tests\\Benchmark\\Generated'; - $generator = new FixtureGenerator($basePath, $namespace); - - $multiInfo = $generator->generateMultiLocation(200, 5); - - $autoloaders = []; - foreach ($multiInfo['locations'] as $loc) { - $al = function (string $class) use ($loc): void { - if (! str_starts_with($class, $loc['namespace'])) { - return; - } - - $relative = str_replace($loc['namespace'].'\\', '', $class); - $file = $loc['path'].'/'.str_replace('\\', '/', $relative).'.php'; - if (file_exists($file)) { - require_once $file; - } - }; - spl_autoload_register($al); - $autoloaders[] = $al; - } - - try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); - - foreach ($multiInfo['locations'] as $loc) { - $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); - } - - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(200); - // 5 modules with subdirs should not be dramatically slower than flat - expect($elapsedMs)->toBeLessThan(5000); - } finally { - foreach ($autoloaders as $al) { - spl_autoload_unregister($al); - } - - $generator->cleanup(); - } - }); -}); diff --git a/tests/Benchmark/Fixtures/FixtureGenerator.php b/tests/Benchmark/Fixtures/FixtureGenerator.php deleted file mode 100644 index c35e6758..00000000 --- a/tests/Benchmark/Fixtures/FixtureGenerator.php +++ /dev/null @@ -1,673 +0,0 @@ -} - */ - public function generate(int $count): array - { - $this->ensureDirectory($this->basePath); - - $distribution = $this->calculateDistribution($count); - $index = 0; - - foreach ($distribution as $type => $typeCount) { - for ($i = 0; $i < $typeCount; $i++) { - $this->generateClass($type, $index); - $index++; - } - } - - return [ - 'path' => $this->basePath, - 'namespace' => $this->namespace, - 'count' => $index, - 'distribution' => $distribution, - ]; - } - - /** - * Generate a multi-location structure simulating a real Pollora project. - * - * Creates N modules, each with a DDD-like directory structure: - * ModuleName/ - * app/ - * PostTypes/ - * Hooks/ - * Http/Controllers/ - * Providers/ - * Jobs/ - * Models/ - * - * Each module is a separate discovery location (like nwidart modules). - * - * @param int $totalClasses Total number of classes across all modules - * @param int $moduleCount Number of modules to create - * @return array{locations: array, total_classes: int, modules: int, dirs_created: int} - */ - public function generateMultiLocation(int $totalClasses, int $moduleCount): array - { - $this->ensureDirectory($this->basePath); - - $classesPerModule = (int) ceil($totalClasses / $moduleCount); - $locations = []; - $totalGenerated = 0; - $dirsCreated = 0; - $remaining = $totalClasses; - - for ($mod = 0; $mod < $moduleCount; $mod++) { - $moduleName = 'Module'.chr(65 + ($mod % 26)).($mod >= 26 ? $mod : ''); - $modulePath = $this->basePath.'/'.$moduleName.'/app'; - $moduleNamespace = $this->namespace.'\\'.$moduleName.'\\App'; - $classCount = min($classesPerModule, $remaining); - - if ($classCount <= 0) { - break; - } - - // DDD subdirectories - $subdirs = [ - 'PostTypes' => 'post_type', - 'Hooks' => 'hook', - 'Http/Controllers' => 'rest', - 'Providers' => 'provider', - 'Jobs' => 'schedule', - 'Models' => 'plain', - 'Services' => 'mixed', - ]; - - $perSubdir = (int) ceil($classCount / count($subdirs)); - $moduleGenerated = 0; - - foreach ($subdirs as $subdir => $type) { - $subdirPath = $modulePath.'/'.$subdir; - $subdirNamespace = $moduleNamespace.'\\'.str_replace('/', '\\', $subdir); - - $subdirCount = min($perSubdir, $classCount - $moduleGenerated); - if ($subdirCount <= 0) { - break; - } - - $this->ensureDirectory($subdirPath); - $dirsCreated++; - - for ($i = 0; $i < $subdirCount; $i++) { - $globalIndex = $totalGenerated + $moduleGenerated + $i; - $className = $this->classNameForType($type, $globalIndex); - $content = $this->generateClassContent($type, $className, $globalIndex, $subdirNamespace); - file_put_contents($subdirPath.'/'.$className.'.php', $content); - } - - $moduleGenerated += $subdirCount; - } - - $locations[] = [ - 'path' => $modulePath, - 'namespace' => $moduleNamespace, - ]; - - $totalGenerated += $moduleGenerated; - $remaining -= $moduleGenerated; - } - - return [ - 'locations' => $locations, - 'total_classes' => $totalGenerated, - 'modules' => count($locations), - 'dirs_created' => $dirsCreated, - ]; - } - - /** - * Clean up generated fixtures. - */ - public function cleanup(): void - { - if (is_dir($this->basePath)) { - $this->removeDirectory($this->basePath); - } - } - - private function calculateDistribution(int $count): array - { - $hooks = (int) round($count * 0.30); - $providers = (int) round($count * 0.15); - $postTypes = (int) round($count * 0.15); - $rest = (int) round($count * 0.10); - $schedule = (int) round($count * 0.10); - $mixed = (int) round($count * 0.10); - $plain = $count - $hooks - $providers - $postTypes - $rest - $schedule - $mixed; - - return [ - 'hook' => $hooks, - 'provider' => $providers, - 'post_type' => $postTypes, - 'rest' => $rest, - 'schedule' => $schedule, - 'mixed' => $mixed, - 'plain' => max(0, $plain), - ]; - } - - private function generateClass(string $type, int $index): void - { - $className = $this->classNameForType($type, $index); - $content = $this->generateClassContent($type, $className, $index, $this->namespace); - - file_put_contents($this->basePath.'/'.$className.'.php', $content); - } - - private function generateClassContent(string $type, string $className, int $index, string $namespace): string - { - return match ($type) { - 'hook' => $this->generateHookClass($className, $index, $namespace), - 'provider' => $this->generateProviderClass($className, $namespace), - 'post_type' => $this->generatePostTypeClass($className, $index, $namespace), - 'rest' => $this->generateRestClass($className, $index, $namespace), - 'schedule' => $this->generateScheduleClass($className, $index, $namespace), - 'mixed' => $this->generateMixedClass($className, $index, $namespace), - 'plain' => $this->generatePlainClass($className, $namespace), - }; - } - - private function classNameForType(string $type, int $index): string - { - return match ($type) { - 'hook' => 'BenchHook'.$index, - 'provider' => 'BenchProvider'.$index, - 'post_type' => 'BenchPostType'.$index, - 'rest' => 'BenchRestController'.$index, - 'schedule' => 'BenchSchedule'.$index, - 'mixed' => 'BenchMixed'.$index, - 'plain' => 'BenchPlain'.$index, - }; - } - - /** - * Hook class: 2-6 methods, each with 1-3 repeatable Action/Filter attributes. - */ - private function generateHookClass(string $className, int $index, ?string $namespace = null): string - { - $ns = $namespace ?? $this->namespace; - $methodCount = ($index % 5) + 2; // 2 to 6 methods - $methods = ''; - - for ($m = 0; $m < $methodCount; $m++) { - $attrCount = ($m % 3) + 1; // 1 to 3 attributes per method - $attrs = ''; - - for ($a = 0; $a < $attrCount; $a++) { - $hookName = sprintf('bench_hook_%d_%d_%d', $index, $m, $a); - $attr = ($m + $a) % 3 === 0 ? 'Filter' : 'Action'; - $priority = (($m * 3) + $a) * 5 + 10; - $attrs .= " #[\\Pollora\\Attributes\\{$attr}(hook: '{$hookName}', priority: {$priority})]\n"; - } - - $methods .= <<namespace; - - return <<namespace; - $slug = 'bench_cpt_'.$index; - - // Pool of class-level PostType sub-attributes - $subAttributes = [ - "#[\\Pollora\\Attributes\\PostType\\Supports(features: ['title', 'editor', 'thumbnail', 'excerpt', 'comments'])]", - "#[\\Pollora\\Attributes\\PostType\\MenuIcon(value: 'dashicons-admin-post')]", - '#[\\Pollora\\Attributes\\PostType\\HasArchive(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\ShowInRest(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\ShowUI(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\Hierarchical(value: false)]', - '#[\\Pollora\\Attributes\\PostType\\PublicPostType(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\MenuPosition(value: 25)]', - '#[\\Pollora\\Attributes\\PostType\\ExcludeFromSearch(value: false)]', - '#[\\Pollora\\Attributes\\PostType\\PubliclyQueryable(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\CanExport(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\DeleteWithUser(value: false)]', - ]; - - // Pick 3-8 sub-attributes based on index - $subAttrCount = ($index % 6) + 3; - $selectedAttrs = array_slice($subAttributes, 0, $subAttrCount); - $attrBlock = implode("\n", $selectedAttrs); - - return <<namespace; - $methodCount = ($index % 4) + 2; // 2 to 5 methods - $methods = ''; - - $httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; - - for ($m = 0; $m < $methodCount; $m++) { - $httpMethod = $httpMethods[$m % count($httpMethods)]; - $methodName = match ($httpMethod) { - 'GET' => $m === 0 ? 'index' : 'show', - 'POST' => 'store', - 'PUT' => 'update', - 'DELETE' => 'destroy', - 'PATCH' => 'patch', - }; - // Avoid duplicate method names - $methodName .= $m > 4 ? $m : ''; - - $methods .= << \$id, 'method' => '{$httpMethod}']; - } - - PHP; - } - - return <<\d+)')] - class {$className} - { - {$methods} - } - - PHP; - } - - /** - * Schedule class: 1-4 methods each with a Schedule attribute. - */ - private function generateScheduleClass(string $className, int $index, ?string $namespace = null): string - { - $ns = $namespace ?? $this->namespace; - $methodCount = ($index % 4) + 1; // 1 to 4 methods - $recurrences = ['hourly', 'daily', 'twicedaily', 'weekly']; - $methods = ''; - - for ($m = 0; $m < $methodCount; $m++) { - $recurrence = $recurrences[$m % count($recurrences)]; - $hookName = sprintf('bench_cron_%d_%d', $index, $m); - $methods .= <<namespace; - $slug = 'bench_mixed_'.$index; - $variant = $index % 3; - - return match ($variant) { - 0 => $this->generatePostTypeWithHooks($className, $index, $slug, $ns), - 1 => $this->generateRestWithHooksAndSchedule($className, $index, $ns), - 2 => $this->generateHeavyMixed($className, $index, $slug, $ns), - }; - } - - private function generatePostTypeWithHooks(string $className, int $index, string $slug, ?string $ns = null): string - { - $ns ??= $this->namespace; - $hookCount = ($index % 3) + 2; // 2-4 hook methods - $methods = ''; - - for ($m = 0; $m < $hookCount; $m++) { - $hookName = sprintf('bench_mixed_hook_%d_%d', $index, $m); - $methods .= <<namespace; - - return << \$id]; - } - - #[\\Pollora\\Attributes\\Action(hook: 'rest_api_init', priority: 10)] - public function onRestInit(): void - { - // Register additional routes - } - - #[\\Pollora\\Attributes\\Action(hook: 'init', priority: 5)] - #[\\Pollora\\Attributes\\Action(hook: 'admin_init', priority: 10)] - public function onInit(): void - { - // Multiple hooks on same method - } - - #[\\Pollora\\Attributes\\Schedule(recurrence: 'daily', hook: 'bench_mixed_cron_{$index}')] - public function cleanup(): void - { - // Scheduled cleanup - } - } - - PHP; - } - - private function generateHeavyMixed(string $className, int $index, string $slug, ?string $ns = null): string - { - $ns ??= $this->namespace; - - return <<namespace; - - return <<isDir()) { - rmdir($item->getPathname()); - } else { - unlink($item->getPathname()); - } - } - - rmdir($path); - } -} diff --git a/tests/Benchmark/run.php b/tests/Benchmark/run.php deleted file mode 100644 index 727cf88d..00000000 --- a/tests/Benchmark/run.php +++ /dev/null @@ -1,54 +0,0 @@ -run($classCounts, $iterations); diff --git a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php index 50f667c2..13112877 100644 --- a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php +++ b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php @@ -5,13 +5,21 @@ use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; +use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver; use Pollora\Discovery\Infrastructure\Providers\DiscoveryServiceProvider; +use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine; use Pollora\Discovery\Infrastructure\Services\InstancePool; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery; +final class InstancePoolTestService +{ + public function __construct(public string $source = 'by reflection') {} +} + beforeEach(function (): void { // DiscoveryServiceProvider depends on DebugDetectorInterface $this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [ @@ -56,6 +64,32 @@ expect($this->app->make(DiscoveryManager::class))->toBe($manager); }); + it('builds discovery classes through the application container', function (): void { + $this->app->bind(InstancePoolTestService::class, fn (): InstancePoolTestService => new InstancePoolTestService('from the container')); + + expect($this->app->make(InstanceResolverInterface::class))->toBeInstanceOf(LaravelInstanceResolver::class) + ->and($this->app->make(InstancePool::class)->getInstance(InstancePoolTestService::class)->source)->toBe('from the container'); + }); + + it('shares one cache manager between the engine and its other readers', function (): void { + $engine = $this->app->make(DiscoveryEngineInterface::class); + + expect($engine->getCacheManager())->toBe($this->app->make(DiscoveryCacheManager::class)); + }); + + it('turns the persistent cache off in debug mode', function (): void { + $this->app->forgetInstance(DiscoveryCacheManager::class); + $this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [ + 'isDebugMode' => true, + ])); + + expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeFalse(); + }); + + it('keeps the persistent cache on outside debug mode', function (): void { + expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeTrue(); + }); + it('registers ServiceProviderDiscovery as singleton for auto-registration', function (): void { expect($this->app->bound(ServiceProviderDiscovery::class))->toBeTrue(); }); diff --git a/tests/Feature/Doctor/InstallChecksTest.php b/tests/Feature/Doctor/InstallChecksTest.php index 29bfbd27..dd5832b8 100644 --- a/tests/Feature/Doctor/InstallChecksTest.php +++ b/tests/Feature/Doctor/InstallChecksTest.php @@ -8,6 +8,7 @@ use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Models\DiscoveryLocation; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck; @@ -213,7 +214,7 @@ function cacheManager(bool $debug): DiscoveryCacheManager $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn($debug); - return new DiscoveryCacheManager(app(), $detector); + return (new LaravelDiscoveryCacheManagerFactory(app(), $detector))->make(); } beforeEach(function (): void { diff --git a/tests/Pest.php b/tests/Pest.php index 37093e09..e5722449 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,7 +17,6 @@ uses(TestCase::class)->in('Feature/Modules', 'Feature/Option'); uses(TestCase::class)->in('Unit'); -uses()->in('Benchmark'); uses(Orchestra\Testbench\TestCase::class) ->beforeEach(function (): void { \Brain\Monkey\setUp(); diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php deleted file mode 100644 index eebaf84c..00000000 --- a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php +++ /dev/null @@ -1,154 +0,0 @@ -isLoaded())->toBeFalse(); - expect($items)->toHaveCount(0); - expect($items->all())->toBe([]); - }); - - it('can create with initial data', function (): void { - $items = new DiscoveryItems([ - 'location1' => ['item1', 'item2'], - 'location2' => ['item3'], - ]); - - expect($items->isLoaded())->toBeTrue(); - expect($items)->toHaveCount(3); - expect($items->all())->toEqual(['item1', 'item2', 'item3']); - }); - - it('can add single item for location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->add($location, 'test-item'); - - expect($items->hasLocation($location))->toBeTrue(); - expect($items->getForLocation($location))->toEqual(['test-item']); - expect($items)->toHaveCount(1); - }); - - it('can add multiple items for location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->addForLocation($location, ['item1', 'item2', 'item3']); - - expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']); - expect($items)->toHaveCount(3); - }); - - it('can add items to existing location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->add($location, 'item1'); - $items->addForLocation($location, ['item2', 'item3']); - - expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']); - expect($items)->toHaveCount(3); - }); - - it('handles multiple locations', function (): void { - $items = new DiscoveryItems; - $location1 = new DiscoveryLocation('App\\Models', '/app/models'); - $location2 = new DiscoveryLocation('App\\Services', '/app/services'); - - $items->addForLocation($location1, ['model1', 'model2']); - $items->addForLocation($location2, ['service1']); - - expect($items)->toHaveCount(3); - expect($items->getForLocation($location1))->toEqual(['model1', 'model2']); - expect($items->getForLocation($location2))->toEqual(['service1']); - expect($items->all())->toEqual(['model1', 'model2', 'service1']); - }); - - it('returns empty array for unknown location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - expect($items->getForLocation($location))->toBe([]); - expect($items->hasLocation($location))->toBeFalse(); - }); - - it('can iterate over all items', function (): void { - $items = new DiscoveryItems; - $location1 = new DiscoveryLocation('App\\Models', '/app/models'); - $location2 = new DiscoveryLocation('App\\Services', '/app/services'); - - $items->addForLocation($location1, ['model1', 'model2']); - $items->addForLocation($location2, ['service1']); - - $iteratedItems = []; - foreach ($items as $item) { - $iteratedItems[] = $item; - } - - expect($iteratedItems)->toEqual(['model1', 'model2', 'service1']); - }); - - it('supports serialization and unserialization', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->addForLocation($location, ['item1', 'item2']); - - $serialized = $items->__serialize(); - expect($serialized)->toBeArray(); - - $newItems = new DiscoveryItems; - $newItems->__unserialize($serialized); - - expect($newItems->all())->toEqual($items->all()); - expect($newItems->count())->toBe($items->count()); - }); - - it('onlyVendor returns new instance', function (): void { - $items = new DiscoveryItems([ - 'location1' => ['item1'], - 'location2' => ['item2'], - ]); - - $vendorItems = $items->onlyVendor(); - - expect($vendorItems)->not->toBe($items); - expect($vendorItems)->toBeInstanceOf(DiscoveryItems::class); - }); -}); - -describe('DiscoveryItems pending items', function (): void { - it('has every item pending until marked applied', function (): void { - $items = new DiscoveryItems; - $items->addForLocation(new DiscoveryLocation('App\\Models', '/app/models'), ['item1', 'item2']); - - expect($items->pending()->all())->toBe(['item1', 'item2']); - - $items->markApplied(); - - expect($items->pending()->isLoaded())->toBeFalse() - ->and($items->all())->toBe(['item1', 'item2']); - }); - - it('keeps as pending only the items added since, in every location', function (): void { - $models = new DiscoveryLocation('App\\Models', '/app/models'); - $theme = new DiscoveryLocation('Theme\\Demo', '/themes/demo'); - $items = new DiscoveryItems; - $items->add($models, 'model1'); - $items->markApplied(); - - $items->add($models, 'model2'); - $items->add($theme, 'theme1'); - - expect($items->pending()->all())->toBe(['model2', 'theme1']) - ->and($items->pending())->not->toBe($items) - ->and($items->all())->toBe(['model1', 'model2', 'theme1']); - }); -}); diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php deleted file mode 100644 index b821be06..00000000 --- a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php +++ /dev/null @@ -1,67 +0,0 @@ -getNamespace())->toBe('App\\Models'); - expect($location->getPath())->toBe('/path/to/models'); - }); - - it('generates unique key based on path', function (): void { - $location1 = new DiscoveryLocation('App\\Models', '/path/to/models'); - $location2 = new DiscoveryLocation('App\\Services', '/path/to/models'); - $location3 = new DiscoveryLocation('App\\Models', '/path/to/services'); - - expect($location1->getKey())->toBe($location2->getKey()); - expect($location1->getKey())->not->toBe($location3->getKey()); - }); - - it('detects vendor locations', function (): void { - $vendorLocation = new DiscoveryLocation('Vendor\\Package', '/path/to/vendor/package'); - $appLocation = new DiscoveryLocation('App\\Models', '/path/to/app/models'); - - expect($vendorLocation->isVendor())->toBeTrue(); - expect($appLocation->isVendor())->toBeFalse(); - }); - - it('detects vendor locations with windows path', function (): void { - $vendorLocation = new DiscoveryLocation('Vendor\\Package', 'C:\\path\\to\\vendor\\package'); - - expect($vendorLocation->isVendor())->toBeTrue(); - }); - - it('converts file path to class name', function (): void { - $location = new DiscoveryLocation('App\\Models', '/app/Models'); - - expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User'); - }); - - it('converts nested file path to class name', function (): void { - $location = new DiscoveryLocation('App\\Services', '/app/Services'); - - expect($location->toClassName('/app/Services/Auth/UserService.php'))->toBe('App\\Services\\Auth\\UserService'); - }); - - it('returns empty string for file outside location', function (): void { - $location = new DiscoveryLocation('App\\Models', '/app/Models'); - - expect($location->toClassName('/app/Services/UserService.php'))->toBe(''); - }); - - it('handles windows paths in class name conversion', function (): void { - $location = new DiscoveryLocation('App\\Models', 'C:\\app\\Models'); - - expect($location->toClassName('C:\\app\\Models\\Auth\\User.php'))->toBe('App\\Models\\Auth\\User'); - }); - - it('trims namespace slashes', function (): void { - $location = new DiscoveryLocation('App\\Models\\', '/app/Models'); - - expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User'); - }); -}); diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php index 1266b4ed..271605d8 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php @@ -7,6 +7,7 @@ use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Models\DiscoveryContext; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Psr\Log\LoggerInterface; @@ -38,15 +39,14 @@ function managerFor(bool $debug, ?LoggerInterface $logger = null): DiscoveryCach $container->instance(LoggerInterface::class, $logger); } - // config() reaches for the application container; without it the - // non-debug path cannot resolve a cache driver. + // Without a config repository the non-debug path cannot resolve a cache driver. $container->instance('config', new Repository(['structure-discoverer' => ['cache' => []]])); Container::setInstance($container); $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn($debug); - return new DiscoveryCacheManager($container, $detector); + return (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); } function scanOf(DiscoveryCacheManager $manager, string $path): void @@ -157,14 +157,14 @@ function scanOf(DiscoveryCacheManager $manager, string $path): void $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn(false); - $first = new DiscoveryCacheManager($container, $detector); + $first = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); scanOf($first, $this->dir); // A new process starts with an empty copy of its own, and only the // persistent cache is left to answer. (new ReflectionProperty(DiscoveryCacheManager::class, 'structuresCache'))->setValue(null, []); - $second = new DiscoveryCacheManager($container, $detector); + $second = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); scanOf($second, $this->dir); expect($first->scans()[0]['source'])->toBe('disk') diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php deleted file mode 100644 index 9987ab03..00000000 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php +++ /dev/null @@ -1,342 +0,0 @@ - true]); - - return new DiscoveryEngine( - $container, - $debugDetector, - cacheManager: $cacheManager ?? Mockery::mock(DiscoveryCacheManager::class) - ); -} - -function createLocationMock(string $path): DiscoveryLocationInterface -{ - $location = Mockery::mock(DiscoveryLocationInterface::class); - $location->shouldReceive('getPath')->andReturn($path); - $location->shouldReceive('getKey')->andReturn('key_'.md5($path)); - $location->shouldReceive('isVendor')->andReturn(false); - - return $location; -} - -describe('DiscoveryEngine', function (): void { - describe('addLocation', function (): void { - it('adds a location', function (): void { - $engine = createEngine(); - $location = createLocationMock('/tmp/test-path-'.uniqid()); - - $result = $engine->addLocation($location); - - expect($result)->toBe($engine); - expect($engine->getLocations())->toHaveCount(1); - }); - - it('deduplicates locations with same path', function (): void { - $engine = createEngine(); - $path = '/tmp/dedup-path-'.uniqid(); - $location1 = createLocationMock($path); - $location2 = createLocationMock($path); - - $engine->addLocation($location1); - $engine->addLocation($location2); - - expect($engine->getLocations())->toHaveCount(1); - }); - - it('adds locations with different paths', function (): void { - $engine = createEngine(); - - $engine->addLocation(createLocationMock('/tmp/path-a-'.uniqid())); - $engine->addLocation(createLocationMock('/tmp/path-b-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(2); - }); - }); - - describe('addLocations', function (): void { - it('adds multiple locations at once', function (): void { - $engine = createEngine(); - - $engine->addLocations([ - createLocationMock('/tmp/loc-1-'.uniqid()), - createLocationMock('/tmp/loc-2-'.uniqid()), - ]); - - expect($engine->getLocations())->toHaveCount(2); - }); - }); - - describe('addDiscovery', function (): void { - it('adds a discovery instance', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('test', $discovery); - - expect($engine->getDiscoveries())->toHaveCount(1); - expect($engine->getDiscoveries()->has('test'))->toBeTrue(); - }); - - it('throws on duplicate identifier', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('dup', $discovery); - - expect(fn (): DiscoveryEngine => $engine->addDiscovery('dup', $discovery)) - ->toThrow(InvalidDiscoveryException::class); - }); - }); - - describe('addDiscoveries', function (): void { - it('adds multiple discoveries at once', function (): void { - $engine = createEngine(); - - $engine->addDiscoveries([ - 'first' => Mockery::mock(DiscoveryInterface::class), - 'second' => Mockery::mock(DiscoveryInterface::class), - ]); - - expect($engine->getDiscoveries())->toHaveCount(2); - }); - }); - - describe('getDiscovery', function (): void { - it('returns discovery by identifier', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('my-discovery', $discovery); - - expect($engine->getDiscovery('my-discovery'))->toBe($discovery); - }); - - it('throws when discovery not found', function (): void { - $engine = createEngine(); - - expect(fn (): DiscoveryInterface => $engine->getDiscovery('nonexistent')) - ->toThrow(DiscoveryNotFoundException::class); - }); - }); - - describe('clearLocations', function (): void { - it('removes all locations', function (): void { - $engine = createEngine(); - $engine->addLocation(createLocationMock('/tmp/clear-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(1); - - $engine->clearLocations(); - - expect($engine->getLocations())->toHaveCount(0); - }); - - it('returns the engine for chaining', function (): void { - $engine = createEngine(); - - expect($engine->clearLocations())->toBe($engine); - }); - }); - - describe('clearCache', function (): void { - it('delegates to cache manager', function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('clearCache')->once(); - - $engine = createEngine($cacheManager); - $engine->clearCache(); - }); - }); - - describe('getPerformanceStats', function (): void { - it('returns stats array with expected keys', function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStaticCacheSize')->andReturn(0); - - $engine = createEngine($cacheManager); - $stats = $engine->getPerformanceStats(); - - expect($stats)->toHaveKeys(['context', 'instance_pool', 'static_cache_size']); - expect($stats['static_cache_size'])->toBe(0); - }); - }); - - describe('__clone', function (): void { - it('creates independent copies of collections', function (): void { - $engine = createEngine(); - $engine->addLocation(createLocationMock('/tmp/clone-'.uniqid())); - - $clone = clone $engine; - $clone->addLocation(createLocationMock('/tmp/clone-extra-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(1); - expect($clone->getLocations())->toHaveCount(2); - }); - }); -}); - -describe('DiscoveryEngine apply', function (): void { - /** - * A discovery that records the items each apply() receives. - */ - function recordingDiscovery(bool $failing = false): DiscoveryInterface - { - return new class($failing) implements DiscoveryInterface - { - use IsDiscovery; - - /** @var list> */ - public array $applied = []; - - public function __construct(private readonly bool $failing) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void {} - - public function apply(): void - { - $this->applied[] = $this->getItems()->all(); - - if ($this->failing) { - throw new RuntimeException('apply failed'); - } - } - }; - } - - it('applies each item once, however many times it is asked to apply', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $app = new DiscoveryLocation('App\\', '/app'); - $theme = new DiscoveryLocation('Theme\\', '/theme'); - - $discovery->getItems()->add($app, 'app-item'); - $engine->apply(); - $engine->apply(); - $discovery->getItems()->add($theme, 'theme-item'); - $engine->apply(); - - expect($discovery->applied)->toBe([['app-item'], ['theme-item']]); - }); - - it('keeps every item readable after applying', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $location = new DiscoveryLocation('App\\', '/app'); - - $discovery->getItems()->add($location, 'first'); - $engine->apply(); - $discovery->getItems()->add($location, 'second'); - $engine->apply(); - - expect($discovery->getItems()->all())->toBe(['first', 'second']); - }); - - it('applies once for the engine and its clones, which share the discovery', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item'); - - $engine->apply(); - (clone $engine)->apply(); - - expect($discovery->applied)->toBe([['item']]); - }); - - it('does not call apply() when nothing new was discovered', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - - $engine->apply(); - - expect($discovery->applied)->toBe([]); - }); - - it('does not retry items whose apply failed, and restores the full list', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(failing: true); - $engine->addDiscovery('recording', $discovery); - $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item'); - - expect(fn (): DiscoveryEngine => $engine->apply())->toThrow(DiscoveryException::class); - - $engine->apply(); - - expect($discovery->applied)->toBe([['item']]) - ->and($discovery->getItems()->all())->toBe(['item']); - }); -}); - -#[CapabilitySet] -enum EngineTestAttributedEnum: string -{ - case Value = 'value'; -} - -enum EngineTestPlainEnum: string -{ - case Value = 'value'; -} - -final class EngineTestClass {} - -abstract class EngineTestAbstractClass {} - -describe('DiscoveryEngine structures', function (): void { - it('hands discoveries concrete classes and enums carrying attributes', function (): void { - $structures = array_map( - fn (string $name): DiscoveredStructure => enum_exists($name) - ? DiscoveredEnum::fromReflection(new ReflectionEnum($name)) - : DiscoveredClass::fromReflection(new ReflectionClass($name)), - [EngineTestAttributedEnum::class, EngineTestPlainEnum::class, EngineTestClass::class, EngineTestAbstractClass::class] - ); - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStructuresForLocation')->andReturn($structures); - $engine = createEngine($cacheManager); - $seen = []; - $discovery = new class($seen) implements DiscoveryInterface - { - use IsDiscovery; - - public function __construct(private array &$seen) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - $this->seen[] = $structure->name; - } - - public function apply(): void {} - }; - $engine->addDiscovery('seen', $discovery); - $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__)); - - $engine->discover(); - - expect($seen)->toBe(['EngineTestAttributedEnum', 'EngineTestClass']); - }); -}); diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php index d30a7ec5..b3c9e069 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php @@ -71,7 +71,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface ->once() ->with('test_discovery', $discovery); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toContain('test_discovery'); }); @@ -82,7 +82,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -96,7 +96,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface ->andReturn(new Collection(['already_registered' => $discovery])); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -107,7 +107,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -118,7 +118,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); });