diff --git a/CHANGELOG.md b/CHANGELOG.md index ba319e6..dcdeb51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Planned + +* Shared dependency graph compilation and analysis. +* Unified dependency candidate selection for static validation. +* Additional validation performance optimizations. + +## [[v0.4.0](https://github.com/The-FireHub-Project/Container/compare/v0.3.0...v0.4.0)] – 2026-10-23 + +### Added +* Comprehensive static container validation through `Container::validate()` by @dieselxxx +* Structured validation results with issue types, severity levels, and dependency paths by @dieselxxx +* Constructor dependency graph validation, including missing dependencies and circular references by @dieselxxx +* Static validation of union, intersection, and disjunctive normal form (DNF) dependencies by @dieselxxx +* Tagged service and collection injection validation by @dieselxxx +* Explicit constructor parameter binding validation by @dieselxxx +* Contextual binding validation, including implementation compatibility and unused bindings by @dieselxxx +* Singleton-to-scoped lifetime mismatch detection for direct and transitive dependencies by @dieselxxx +* Lifetime validation supports for contextual, composite, tagged, collection, and aliased dependencies by @dieselxxx +* Runtime lifetime protection regression tests by @dieselxxx +* Shared constructor metadata, dependency plan, and type-matching caches across validation stages by @dieselxxx +* Canonical service identifier caching for alias resolution by @dieselxxx +* Expanded PHPUnit coverage for dependency validation and service lifetime behavior by @dieselxxx + +### Changed +* Consolidated constructor metadata analysis across validation components by @dieselxxx +* Reduced redundant reflection and dependency plan compilation during validation by @dieselxxx +* Improved consistency between static dependency analysis and runtime dependency resolution by @dieselxxx +* Optimized alias resolution through shared caching within individual validation operations by @dieselxxx + ## [[v0.3.0](https://github.com/The-FireHub-Project/Container/compare/v0.2.0...v0.3.0)] – 2026-10-22 ### Added diff --git a/README.md b/README.md index 0ebc44e..ac570b7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /> version @@ -22,10 +22,6 @@ /> -[![PHPStan](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml) -[![PHPUnit](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml) -[![Codecov](https://codecov.io/gh/The-FireHub-Project/Container/branch/develop/graph/badge.svg?token=XW2YEONF51)](https://app.codecov.io/gh/The-FireHub-Project/Container/tree/develop) -

FireHub Icon Development Branch +## FireHub Icon Repository Purpose + +FireHub Container is the dependency injection and service resolution component of the FireHub ecosystem. + +Built on the contracts provided by FireHub Core, it supplies the mechanisms required to register, construct, resolve, and manage application services. + +The purpose of this repository is to centralize dependency management while preserving separation of concerns between service definitions, runtime implementation, and application execution. -⚠️ **This is the `develop` branch** +The Container architecture is designed to support: -- Unstable -- APIs may change without notice -- Not intended for production use +- Service registration and dependency resolution +- Interface-to-implementation bindings +- Instance, factory, and class-based service definitions +- Singleton and transient service lifetimes +- Dependency graph resolution and circular dependency detection +- Immutable service registrations after container construction +- Future support for autowiring and execution-scoped services -## FireHub Icon Branch Purpose +FireHub Container follows the architectural principles of The FireHub Project by separating responsibilities between contracts, dependency management, and application lifecycle orchestration. -The `develop` branch is the **primary integration branch** for all ongoing development. +The Container does not control application execution or kernel lifecycle transitions. Instead, it provides dependency management services that can be integrated into applications through the appropriate bootstrap and composition mechanisms. -It serves as the staging area where: -- Feature branches are merged -- Bug fixes are integrated -- Experimental work is stabilized -- Code is prepared for upcoming releases +## FireHub Icon Security -All **release branches** are created **from `develop`**. +For security reporting and guidelines, please refer to our [SECURITY.md](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file. -## Stability Guarantee +## FireHub Icon Community -❌ No backward compatibility guarantee -❌ APIs may change without notice -❌ Behavior may be incomplete or inconsistent -❌ Breaking changes are expected +![GitHub watchers](https://img.shields.io/github/watchers/The-FireHub-Project/Container?style=social) +![GitHub Repo stars](https://img.shields.io/github/stars/The-FireHub-Project/Container?style=social) +![GitHub forks](https://img.shields.io/github/forks/The-FireHub-Project/Container?style=social) -This branch is intended **only for contributors and advanced testers**. +- Be sure to review the [Code of Conduct](https://github.com/The-FireHub-Project/.github/blob/master/.github/CODE_OF_CONDUCT.md). +- Read a [Contributing](https://github.com/The-FireHub-Project/.github/blob/master/.github/CONTRIBUTING.md) file to see how you can contribute to this project. +- Read our [Security policy](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file to see how you can report a vulnerability. +- Review the project [Governance model](https://github.com/The-FireHub-Project/.github/blob/master/.github/GOVERNANCE.md), including roles, decision-making, RFC process, and releases. -## FireHub Icon Composer Usage (Not Recommended) +## FireHub Icon Support + +

+ + GitHub in-progress issues + + GitHub total issues + + GitHub opened pull requests +

-For internal testing only: +For help with FireHub products, please refer to the following resources: -```json -{ - "require": { - "the-firehub-project/container": "dev-develop" - } -} -``` -⚠️ Never use dev-develop in production. +- **General questions & discussions**: [GitHub Discussions](https://github.com/orgs/The-FireHub-Project/discussions) +- **Bug reports & issues**: [Support](https://github.com/The-FireHub-Project/Container/issues) +- **Documentation & guides**: [FireHub Docs](https://the-firehub-project.github.io) +- **Commercial support** (Pro/Enterprise): contact your account manager ## FireHub Icon Authors and Contributors @@ -134,4 +152,4 @@ Architecture guidelines, design principles, and ecosystem documentation are avai This software is licensed under the Apache-2.0 License. -For more details, read the full license [here](./LICENSE). \ No newline at end of file +For more details, read the full license [here](./LICENSE). diff --git a/src/Container.php b/src/Container.php index ba31a08..079bb9a 100644 --- a/src/Container.php +++ b/src/Container.php @@ -24,6 +24,7 @@ ContainerState, ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeRegistry, TagRegistry }; +use FireHub\Container\Validation\Result; use FireHub\Container\Exception\ { ContainerActiveScopesException, InvalidTagNameException, ServiceNotFoundException }; @@ -1213,4 +1214,33 @@ public function isFrozen ():bool { } + /** + * ### Validates registered service definitions + * + * Performs static validation of the container configuration without instantiating services or executing factory + * callbacks. + * + * Validation does not freeze or modify the container. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation::validate() To validate registered service definitions. + * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a constructor parameter contains invalid + * injection attributes or unsupported dependency metadata. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the composite type is unsupported. + * + * @return \FireHub\Container\Validation\Result Container validation result. + */ + public function validate ():Result { + + return new Validator( + $this->registry, + $this->parameters, + $this->contextual, + $this->tags + )->validate(); + + } + } \ No newline at end of file diff --git a/src/Internal/CanonicalIdCache.php b/src/Internal/CanonicalIdCache.php new file mode 100644 index 0000000..8d6d62d --- /dev/null +++ b/src/Internal/CanonicalIdCache.php @@ -0,0 +1,132 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Internal; + +use FireHub\Container\Registry; +use FireHub\Container\Definition\Alias; +use FireHub\Runtime; + +/** + * ### Caches canonical service identifiers + * + * Resolves registered alias chains to their final service identifiers. + * + * Successfully resolved identifiers and invalid alias chains are cached for reuse throughout a single container + * validation operation. + * + * Circular and unresolved aliases return null. + * + * The cache assumes that service registrations remain unchanged during its lifetime. + * @since 1.0.0 + * + * @internal + */ +final class CanonicalIdCache { + + /** + * ### Cached canonical identifiers + * @since 1.0.0 + * + * @var array + */ + private array $cache = []; + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Registry $registry

+ * Registered service definitions. + *

+ */ + public function __construct ( + private readonly Registry $registry + ) {} + + /** + * ### Gets the canonical service identifier + * + * Follows alias chains and caches their resolved targets. + * + * Invalid and circular alias chains are cached as null. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::find() To retrieve a service definition. + * @uses \FireHub\Container\Registry::has() To check if an alias target exists. + * @uses \FireHub\Runtime\Arr\Access::keyExists() To check if a key exists in an array. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @return null|class-string Canonical service identifier. + */ + public function get (string $id):?string { + + if (Runtime\Arr\Access::keyExists($this->cache, $id)) + return $this->cache[$id]; // @phpstan-ignore offsetAccess.notFound + + $visited = []; $path = []; $current = $id; + while (true) { + + if (Runtime\Arr\Access::keyExists($this->cache, $id)) { // @phpstan-ignore if.alwaysFalse + + $canonical = $this->cache[$current]; // @phpstan-ignore offsetAccess.notFound + + break; + + } + + if (isset($visited[$current])) { + + $canonical = null; + + break; + + } + + $visited[$current] = true; + + $path[] = $current; + + $definition = $this->registry->find($current); + + if (!$definition instanceof Alias) { + + $canonical = $current; + + break; + + } + + $current = $definition->target; + + if (!$this->registry->has($current)) { + + $canonical = null; + + break; + + } + + } + + foreach ($path as $visited_id) + $this->cache[$visited_id] = $canonical; + + return $canonical; + + } + +} \ No newline at end of file diff --git a/src/Internal/ContextualBindingRegistry.php b/src/Internal/ContextualBindingRegistry.php index 03e69db..4e1d2cc 100644 --- a/src/Internal/ContextualBindingRegistry.php +++ b/src/Internal/ContextualBindingRegistry.php @@ -110,4 +110,20 @@ public function has (string $consumer, string $dependency):bool { } + /** + * ### Gets all registered contextual dependency bindings + * + * Returns contextual bindings grouped by their consumer implementation classes. + * + * The returned collection preserves the registration order and does not modify the registry. + * @since 1.0.0 + * + * @return array> Registered contextual dependency bindings. + */ + public function all ():array { + + return $this->bindings; + + } + } \ No newline at end of file diff --git a/src/Internal/ParameterBindingRegistry.php b/src/Internal/ParameterBindingRegistry.php index f3aabd7..8379de2 100644 --- a/src/Internal/ParameterBindingRegistry.php +++ b/src/Internal/ParameterBindingRegistry.php @@ -99,4 +99,18 @@ public function get (string $class, string $parameter):mixed { } + /** + * ### Gets all registered constructor parameter bindings + * + * Returns parameter bindings grouped by their implementation classes without modifying the registry. + * @since 1.0.0 + * + * @return array> Registered parameter bindings. + */ + public function all ():array { + + return $this->bindings; + + } + } \ No newline at end of file diff --git a/src/Validation/ContextualBinding.php b/src/Validation/ContextualBinding.php new file mode 100644 index 0000000..aea384c --- /dev/null +++ b/src/Validation/ContextualBinding.php @@ -0,0 +1,355 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container + */ + +namespace FireHub\Container\Validation; + +use FireHub\Container\Registry; +use FireHub\Container\Definition\ { + Alias, Cls, Factory, Instance +}; +use FireHub\Container\Internal\ { + ContextualBindingRegistry, DependencyPlanCache, ParameterBindingRegistry +}; +use FireHub\Container\Exception\ServiceInstantiationException; +use FireHub\Runtime; +use ReflectionClass, ReflectionIntersectionType, ReflectionNamedType, ReflectionType, ReflectionUnionType; + +/** + * ### Validates contextual dependency binding registrations + * + * Performs static validation of contextual dependency configuration independently of dependency graph traversal. + * + * Checks consumer classes, dependency contracts, implementation availability, and statically determinable type + * compatibility without instantiating services or executing factory callbacks. + * @since 1.0.0 + * + * @internal + */ +final readonly class ContextualBinding { + + /** + * ### Constructor dependency plan cache + * @since 1.0.0 + */ + private DependencyPlanCache $plans; + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Registry $registry

+ * Registered service definitions. + *

+ * @param \FireHub\Container\Internal\ContextualBindingRegistry $bindings

+ * Registered contextual dependency bindings. + *

+ * @param \FireHub\Container\Internal\ParameterBindingRegistry $parameters

+ * Registered constructor parameter bindings. + *

+ * @param \FireHub\Container\Internal\DependencyPlanCache $plans

+ * Compiled dependency plan cache. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry, + private ContextualBindingRegistry $bindings, + private ParameterBindingRegistry $parameters, + DependencyPlanCache $plans + ) { + + $this->plans = $plans; + + } + + /** + * ### Validates contextual dependency bindings + * + * Inspects every contextual registration and reports structurally invalid bindings. + * + * Factory implementations are not executed because their return types cannot be verified statically. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContextualBindingRegistry::all() To retrieve contextual bindings. + * @uses \FireHub\Container\Validation\ContextualBinding::implementationOf() To resolve implementations. + * @uses \FireHub\Container\Validation\ContextualBinding::isUsed() To check if a dependency is used. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check if the identifier is a class. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check if the identifier is an interface. + * @uses \FireHub\Runtime\ObjectModel\Relation::instanceOf() To check if the implementation satisfies the contract. + * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a constructor parameter contains invalid + * injection attributes or unsupported dependency metadata. + * + * @return list<\FireHub\Container\Validation\Issue> Detected validation issues. + */ + public function validate ():array { + + $issues = []; + + foreach ($this->bindings->all() as $consumer => $bindings) { + + if (!Runtime\ObjectModel\Inspection::isClass($consumer)) { + + $issues[] = new Issue( + IssueType::INVALID_CONTEXTUAL_BINDING, + Severity::ERROR, + $consumer, + "Contextual binding consumer '$consumer' does not exist." + ); + + continue; + + } + + $reflection = new ReflectionClass($consumer); + + if (!$reflection->isInstantiable()) { + + $issues[] = new Issue( + IssueType::INVALID_CONTEXTUAL_BINDING, + Severity::ERROR, + $consumer, + "Contextual binding consumer '$consumer' is not instantiable." + ); + + continue; + + } + + foreach ($bindings as $dependency => $implementation) { + + if ( + !Runtime\ObjectModel\Inspection::isClass($dependency) + && !Runtime\ObjectModel\Inspection::isInterface($dependency) + ) { + + $issues[] = new Issue( + IssueType::INVALID_CONTEXTUAL_BINDING, + Severity::ERROR, + $consumer, + "Contextual dependency contract '$dependency' does not exist." + ); + + continue; + + } + + $resolved = $this->implementationOf($implementation); + + if ($resolved === false) { + + $issues[] = new Issue( + IssueType::INVALID_CONTEXTUAL_BINDING, + Severity::ERROR, + $consumer, + "Contextual implementation '$implementation' cannot be resolved." + ); + + continue; + + } + + if ( + $resolved !== null + && !Runtime\ObjectModel\Relation::instanceOf($resolved, $dependency) + ) { + + $issues[] = new Issue( + IssueType::INVALID_CONTEXTUAL_BINDING, + Severity::ERROR, + $consumer, + "Contextual implementation '$resolved' does not satisfy dependency '$dependency'." + ); + + continue; + + } + + try { + + $used = $this->isUsed($consumer, $dependency); + + } catch (ServiceInstantiationException) { + + continue; + + } + + if (!$used) { + + $issues[] = new Issue( + IssueType::UNUSED_CONTEXTUAL_BINDING, + Severity::WARNING, + $consumer, + "Contextual dependency '$dependency' is not used by constructor injection in '$consumer'." + ); + + } + + } + + } + + return $issues; + + } + + /** + * ### Resolves a statically known contextual implementation + * + * Follows registered service aliases and returns their concrete implementation types. + * + * Returns null for registered factories because their runtime return types cannot be determined without + * executing the callback. Returns false for missing, invalid, or circular alias targets. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::find() To retrieve service definitions. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check if the identifier is a class. + * + * @param class-string $id

+ * Contextual implementation identifier. + *

+ * + * @return class-string|false|null Statically known implementation, invalid target, or unverifiable factory. + */ + private function implementationOf (string $id):string|false|null { + + $visited = []; + + while (true) { + + if (isset($visited[$id])) return false; + + $visited[$id] = true; + + $definition = $this->registry->find($id); + + if ($definition instanceof Alias) { + + if (!$this->registry->has($definition->target)) return false; + + $id = $definition->target; + + continue; + + } + + if ($definition instanceof Cls) return $definition->class; + + if ($definition instanceof Instance) return $definition->instance::class; + + if ($definition instanceof Factory) return null; + + if (!Runtime\ObjectModel\Inspection::isClass($id)) return false; + + $reflection = new ReflectionClass($id); + + return $reflection->isInstantiable() + ? $id + : false; + + } + + } + + /** + * ### Checks whether a contextual binding can participate in constructor injection + * + * Inspects compiled constructor parameter metadata and determines whether a contextual dependency contract is + * referenced by an eligible parameter. + * + * Named, union, intersection, and DNF parameter types are inspected recursively. + * + * Explicit parameter bindings and collection injection attributes take precedence over contextual resolution. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve compiled constructor metadata. + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check explicit parameter bindings. + * @uses \FireHub\Container\Validation\ContextualBindingValidator::containsType() To inspect declared types. + * + * @param class-string $consumer

+ * Consumer implementation class. + *

+ * @param class-string $dependency

+ * Contextual dependency contract. + *

+ * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a constructor parameter contains invalid + * injection attributes or unsupported dependency metadata. + * + * @return bool True if the contextual binding can be selected for at least one constructor parameter. + */ + private function isUsed (string $consumer, string $dependency):bool { + + $plan = $this->plans->get($consumer); + + foreach ($plan->parameters as $parameter) { + + $name = $parameter->parameter->getName(); + + if ($this->parameters->has($consumer, $name)) + continue; + + if ($parameter->tag !== null || $parameter->collection !== null) + continue; + + $type = $parameter->parameter->getType(); + + if ( + $type !== null + && $this->containsType($type, $dependency) + ) return true; + + } + + return false; + + } + + /** + * ### Checks whether a declared parameter type references a dependency contract + * + * Traverses named, union, intersection, and DNF types recursively without resolving service instances. + * @since 1.0.0 + * + * @param ReflectionType $type

+ * Declared constructor parameter type. + *

+ * @param class-string $dependency

+ * Required dependency contract. + *

+ * + * @return bool True if the dependency contract occurs within the declared type. + */ + private function containsType (ReflectionType $type, string $dependency):bool { + + if ($type instanceof ReflectionNamedType) + return !$type->isBuiltin() && $type->getName() === $dependency; + + if ( + $type instanceof ReflectionUnionType + || $type instanceof ReflectionIntersectionType + ) { + + foreach ($type->getTypes() as $member) + if ($this->containsType($member, $dependency)) + return true; + + } + + return false; + + } + +} \ No newline at end of file diff --git a/src/Validation/DependencyGraph.php b/src/Validation/DependencyGraph.php new file mode 100644 index 0000000..8293b64 --- /dev/null +++ b/src/Validation/DependencyGraph.php @@ -0,0 +1,992 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Validation; + +use FireHub\Container\Registry; +use FireHub\Container\Definition\ { + Alias, Cls, Factory, Instance +}; +use FireHub\Container\Internal\ { + CandidateSelector, CanonicalIdCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlan, + DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, TagRegistry, TypeMatcher +}; +use FireHub\Container\Exception\ { + AmbiguousDependencyException, ServiceInstantiationException +}; +use FireHub\Runtime; +use ReflectionIntersectionType, ReflectionUnionType; + +/** + * ### Validates constructor dependency graphs + * + * Performs static analysis of constructor dependencies using the same compiled dependency metadata used by the + * container's runtime autowiring system. + * + * Detects unresolved required dependencies and circular dependency relationships without constructing services, + * resolving instances, or executing factory callbacks. + * + * Explicit parameter bindings are respected and take precedence over automatic dependency resolution. + * + * This initial implementation analyzes named dependencies. Composite types, contextual bindings, and collection + * injection are reserved for later validation stages. + * @since 1.0.0 + * + * @internal + */ +final readonly class DependencyGraph { + + /** + * ### Compiled dependency plan cache + * @since 1.0.0 + */ + private DependencyPlanCache $plans; + + /** + * ### Dependency type matcher + * @since 1.0.0 + */ + private TypeMatcher $matcher; + + /** + * ### Contextual dependency candidate selector + * + * Selects contextual dependency implementations using the same rules as runtime dependency resolution. + * @since 1.0.0 + */ + private ContextualCandidateSelector $contextual_selector; + + /** + * ### Dependency candidate selector + * + * Selects and validates dependency implementations using the same type compatibility rules as runtime resolution. + * @since 1.0.0 + */ + private CandidateSelector $selector; + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::has() To check if a service is registered. + * @uses \FireHub\Container\Registry::implementations() To retrieve all registered service implementations. + * + * @param \FireHub\Container\Registry $registry

+ * Registered service definitions. + *

+ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

+ * Explicit constructor parameter bindings. + *

+ * @param \FireHub\Container\Internal\ContextualBindingRegistry $contextual

+ * Contextual dependency binding registry. + *

+ * @param \FireHub\Container\Internal\TagRegistry $tags

+ * Registered service tags. + *

+ * @param \FireHub\Container\Internal\DependencyPlanCache $plans

+ * Compiled dependency plan cache. + *

+ * @param \FireHub\Container\Internal\TypeMatcher $matcher

+ * Dependency type matcher. + *

+ * @param \FireHub\Container\Internal\CanonicalIdCache $canonical

+ * Canonical service identifier cache. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry, + private ParameterBindingRegistry $bindings, + ContextualBindingRegistry $contextual, + private TagRegistry $tags, + DependencyPlanCache $plans, + TypeMatcher $matcher, + private CanonicalIdCache $canonical + ) { + + $this->plans = $plans; + + $this->matcher = $matcher; + + $this->contextual_selector = new ContextualCandidateSelector( + $contextual + ); + + $this->selector = new CandidateSelector( + $this->matcher, + fn (string $id):bool => $this->registry->has($id), + fn ():iterable => $this->registry->implementations() + ); + + } + + /** + * ### Validates registered constructor dependency graphs + * + * Traverses class-based service definitions and their statically resolvable constructor dependencies. + * + * Each dependency path is analyzed independently to detect cycles without executing the corresponding + * constructors. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\DependencyGraph::inspect() To validate dependency paths. + * @uses \FireHub\Container\Internal\TypeMatcher::isConstructible() To check if a class is constructible. + * @uses \FireHub\Container\Registry::all() To retrieve all registered service definitions. + * + * @return list<\FireHub\Container\Validation\Issue> Detected dependency validation issues. + */ + public function validate ():array { + + $issues = []; $reported_cycles = []; + foreach ($this->registry->all() as $id => $definition) { + + if (!$definition instanceof Cls) + continue; + + if (!$this->matcher->isConstructible($definition->class)) + continue; + + $this->inspect( + $id, + [], + $issues, + $reported_cycles + ); + + } + + return $issues; + + } + + /** + * ### Inspects a service dependency path + * + * Recursively examines constructor dependencies while tracking the active service path. + * + * A dependency is considered circular when its service identifier already appears in the active path. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\DependencyGraph::selectCompositeDependency() To select a composite dependency. + * @uses \FireHub\Container\Validation\DependencyGraph::reportCycle() To report a dependency cycle. + * @uses \FireHub\Container\Validation\DependencyGraph::canonicalId() To resolve a canonical service identifier. + * @uses \FireHub\Container\Validation\DependencyGraph::inspectTaggedDependencies() To validate tagged dependencies. + * @uses \FireHub\Container\Validation\DependencyGraph::inspectCollectionDependencies() To validate collection + * dependencies. + * @uses \FireHub\Container\Registry::find() To retrieve a service definition. + * @uses \FireHub\Container\Validation\DependencyGraph::inspect() To recursively validate child dependencies. + * @uses \FireHub\Container\Validation\DependencyGraph::selectContextualDependency() To select contextual + * dependencies. + * @uses \FireHub\Container\Validation\DependencyGraph::inspectContextualDependency() To validate contextual + * dependencies. + * @uses \FireHub\Container\Internal\TypeMatcher::isConstructible() To check if a class is constructible. + * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve a compiled dependency plan. + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check if a parameter is explicitly bound. + * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if a service identifier is already in the path. + * @uses \FireHub\Runtime\Str\SB\Delimiter::implode() To join path segments. + * + * @param class-string $id

+ * Service identifier being inspected. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported_cycles

+ * Canonical signatures of dependency cycles already reported during the current validation run. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the union contains an unsupported + * intersection member. + * + * @return void + */ + private function inspect (string $id, array $path, array &$issues, array &$reported_cycles):void { + + $canonical = $this->canonicalId($id); + + if ($canonical === null) return; + + $id = $canonical; + + if (Runtime\Arr\Inspection::inArray($path, $id)) { + + $this->reportCycle( + $id, + $path, + $issues, + $reported_cycles + ); + + return; + + } + + $path[] = $id; + + $definition = $this->registry->find($id); + + if ($definition instanceof Instance || $definition instanceof Factory) + return; + + $class = $definition instanceof Cls + ? $definition->class + : $id; + + if (!$this->matcher->isConstructible($class)) + return; + + $plan = $this->safeDependencyPlan( + $class, + $path, + $issues + ); + + if ($plan === null) return; + + foreach ($plan->parameters as $parameter) { + + $name = $parameter->parameter->getName(); + + if ($this->bindings->has($class, $name)) continue; + + if ($parameter->tag !== null) { + + $this->inspectTaggedDependencies( + $parameter->tag, + $path, + $issues, + $reported_cycles + ); + + continue; + + } + + if ($parameter->collection !== null) { + + $this->inspectCollectionDependencies( + $parameter->collection, + $path, + $issues, + $reported_cycles + ); + + continue; + + } + + [$ambiguous, $contextual] = $this->selectContextualDependency( + $class, + $parameter, + $path, + $issues + ); + + if ($ambiguous) continue; + + if ($contextual !== null) { + + $this->inspectContextualDependency( + $class, + $contextual, + $parameter, + $path, + $issues, + $reported_cycles + ); + + continue; + + } + + if ($parameter->composite !== null) { + + [$ambiguous, $dependency] = $this->selectCompositeDependency( + $class, + $parameter, + $path, + $issues + ); + + if ($ambiguous) continue; + + } else $dependency = $parameter->id; + + if ($dependency === null) { + + if (!$parameter->hasDefault) { + + $issues[] = new Issue( + IssueType::MISSING_DEPENDENCY, + Severity::ERROR, + $id, + "Required constructor parameter '$name' of '$class' cannot be automatically resolved.", + $path + ); + + } + + continue; + + } + + if ( + !$this->registry->has($dependency) + && !$this->matcher->isConstructible($dependency) + ) { + + if (!$parameter->hasDefault) { + + $issues[] = new Issue( + IssueType::MISSING_DEPENDENCY, + Severity::ERROR, + $id, + "Required dependency '$dependency' of '$class' is not registered or constructible.", + [...$path, $dependency] + ); + + } + + continue; + + } + + $this->inspect( + $dependency, + $path, + $issues, + $reported_cycles + ); + + } + + } + + /** + * ### Selects a contextual constructor dependency + * + * Evaluates contextual bindings for the specified consumer and compiled constructor parameter. + * + * Contextual candidate selection follows the same rules as runtime dependency resolution, including support for + * named, union, intersection, and disjunctive normal form dependency types. + * + * Ambiguous contextual bindings are reported as validation issues without interrupting the validation of other + * services. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ContextualCandidateSelector::select() To select contextual dependencies. + * + * @param class-string $consumer

+ * Concrete consumer implementation class. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled constructor parameter. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * + * @return array{0: bool, 1: null|class-string} Whether contextual selection was ambiguous and the selected + * dependency identifier, if available. + */ + private function selectContextualDependency (string $consumer, ParameterPlan $parameter, array $path, array &$issues):array { + + try { + + return [ + false, + $this->contextual_selector->select( + $consumer, + $parameter + ) + ]; + + } catch (AmbiguousDependencyException) { + + $name = $parameter->parameter->getName(); + + $issues[] = new Issue( + IssueType::AMBIGUOUS_DEPENDENCY, + Severity::ERROR, + $consumer, + "Multiple contextual implementations are eligible for parameter '$name' of '$consumer'.", + $path + ); + + return [true, null]; + + } + + } + + /** + * ### Inspects a contextually selected dependency + * + * Validates a contextual dependency target and continues dependency graph traversal when its implementation + * can be determined statically. + * + * Registered factory targets cannot be fully inspected without execution. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::has() To check if a service is registered. + * @uses \FireHub\Container\Internal\TypeMatcher::isConstructible() To check if a class is constructible. + * @uses \FireHub\Container\Validation\DependencyGraph::inspect() To recursively validate child dependencies. + * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To check if a service implementation + * satisfies a dependency type. + * @uses \FireHub\Container\Registry::has() To check if a service is registered. + * @uses \FireHub\Container\Registry::implementations() To retrieve all registered service implementations. + * @uses \FireHub\Container\Validation\DependencyGraph::inspect() To recursively validate child dependencies. + * @uses \FireHub\Container\Validation\DependencyGraph::implementationOf() To find the statically known + * implementation. + * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To check if a service implementation. + * + * @param class-string $consumer

+ * Consumer implementation class. + *

+ * @param class-string $dependency

+ * Selected contextual dependency identifier. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled constructor parameter. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported_cycles

+ * Canonical signatures of dependency cycles already reported during the current validation run. + *

+ * + * @return void + */ + private function inspectContextualDependency (string $consumer, string $dependency, ParameterPlan $parameter, array $path, array &$issues, array &$reported_cycles):void { + + if ( + !$this->registry->has($dependency) + && !$this->matcher->isConstructible($dependency) + ) { + + $issues[] = new Issue( + IssueType::MISSING_DEPENDENCY, + Severity::ERROR, + $consumer, + "Contextual dependency '$dependency' of '$consumer' is not registered or constructible.", + [...$path, $dependency] + ); + + return; + + } + + $implementation = $this->implementationOf($dependency); + + if ($implementation !== null) { + + $type = $parameter->parameter->getType(); + + if ( + $type !== null + && !$this->selector->matchesType( + $implementation, + $parameter->parameter, + $type + ) + ) { + + $issues[] = new Issue( + IssueType::INVALID_CONTEXTUAL_BINDING, + Severity::ERROR, + $consumer, + "Contextual implementation '$implementation' does not satisfy parameter '{$parameter->parameter->getName()}' of '$consumer'.", + [...$path, $dependency] + ); + + return; + + } + + } + $this->inspect( + $dependency, + $path, + $issues, + $reported_cycles + ); + + } + + /** + * ### Finds the statically known implementation of a service + * + * Follows registered alias chains and returns the concrete implementation class when it can be determined without + * resolving the service. + * + * Factory definitions are excluded because their runtime return types cannot be guaranteed statically. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::find() To retrieve a service definition. + * @uses \FireHub\Container\Internal\TypeMatcher::isConstructible() To check if a class is constructible. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @return null|class-string Statically known implementation class, or null when unavailable. + */ + private function implementationOf (string $id):?string { + + $visited = []; + + while (true) { + + if (isset($visited[$id])) + return null; + + $visited[$id] = true; + + $definition = $this->registry->find($id); + + if ($definition instanceof Alias) { + + $id = $definition->target; + + continue; + + } + + if ($definition instanceof Cls) + return $definition->class; + + if ($definition instanceof Instance) + return $definition->instance::class; + + if ($definition instanceof Factory) + return null; + + return $this->matcher->isConstructible($id) + ? $id + : null; + + } + + } + + /** + * ### Resolves a canonical service identifier + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\Canonical::get() To retrieve the canonical identifier. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @return null|class-string Canonical service identifier. + */ + private function canonicalId (string $id):?string { + + return $this->canonical->get($id); + + } + + /** + * ### Safely retrieves a constructor dependency plan + * + * Compiles constructor dependency metadata and converts invalid dependency plans into validation issues. + * + * Compilation failures do not interrupt the validation of other registered services. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve the dependency plan. + * + * @param class-string $class

+ * Implementation class. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * + * @return null|\FireHub\Container\Internal\DependencyPlan Compiled dependency plan, or null when invalid. + */ + private function safeDependencyPlan (string $class, array $path, array &$issues):?DependencyPlan { + + try { + + return $this->plans->get($class); + + } catch (ServiceInstantiationException $exception) { + + $issues[] = new Issue( + IssueType::INVALID_DEPENDENCY_PLAN, + Severity::ERROR, + $class, + "Unable to compile constructor dependency plan for '$class': {$exception->getMessage()}", + $path + ); + + return null; + + } + + } + + /** + * ### Reports a unique circular dependency + * + * Normalizes directed dependency cycles to prevent duplicate reports caused by different traversal starting points. + * + * Cyclic rotations are considered equivalent, while reversed paths remain distinct. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Access::search() To find the starting index of a cycle. + * @uses \FireHub\Runtime\Arr\Structure::slice() To extract a subarray of a cycle. + * @uses \FireHub\Runtime\Arr\Inspection::count() To determine the length of a cycle. + * @uses \FireHub\Runtime\Arr\Ordering::sort() To sort a cycle signature array. + * @uses \FireHub\Runtime\Str\SB\Delimiter::implode() To join a cycle signature array. + * @uses \FireHub\Runtime\Data::serialize() To serialize signatures. + * + * @param class-string $id

+ * Service identifier closing the cycle. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported_cycles

+ * Previously reported cycle signatures. + *

+ * + * @return void + */ + private function reportCycle (string $id, array $path, array &$issues, array &$reported_cycles):void { + + $start = Runtime\Arr\Access::search($path, $id); + + if ($start === false) + return; + + $cycle = Runtime\Arr\Structure::slice($path, $start); + + $count = Runtime\Arr\Inspection::count($cycle); + + if ($count === 0) return; + + $signatures = []; + + for ($index = 0; $index < $count; $index++) { + + $rotation = [ + ...Runtime\Arr\Structure::slice($cycle, $index), + ...Runtime\Arr\Structure::slice($cycle, 0, $index) + ]; + + $signatures[] = Runtime\Data::serialize($rotation); + + } + + Runtime\Arr\Ordering::sort($signatures, type: Runtime\Type\Arr\SortType::STRING); + + /** @var non-empty-string $signature */ + $signature = $signatures[0]; // @phpstan-ignore offsetAccess.notFound + + if (isset($reported_cycles[$signature])) return; + + $reported_cycles[$signature] = true; + + $cycle[] = $id; + + $issues[] = new Issue( + IssueType::CIRCULAR_DEPENDENCY, + Severity::ERROR, + $id, + 'Circular dependency detected: ' . Runtime\Str\SB\Delimiter::implode($cycle, ' -> '), + $cycle + ); + + } + + /** + * ### Selects a composite constructor dependency + * + * Resolves a statically identifiable service candidate for a constructor parameter declared with a union, + * intersection, or disjunctive normal form type. + * + * Candidate selection follows the same rules as runtime dependency resolution by delegating to the shared + * candidate selector. + * + * Union types require a uniquely identifiable eligible implementation. Intersection types require an implementation + * satisfying every declared constituent type. Disjunctive normal form types are evaluated through their union + * representation, allowing intersection branches to participate in candidate selection. + * + * Ambiguous candidate selection is converted into a validation issue without interrupting dependency graph analysis. + * + * The method does not instantiate services, execute factories, or modify container registrations. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\CandidateSelector::union() To select union and DNF dependencies. + * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select intersection dependencies. + * + * @param class-string $consumer

+ * Concrete consumer implementation class. + *

+ * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled constructor parameter. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the union contains an unsupported + * intersection member. + * + * @return array{0: bool, 1: null|class-string} Whether candidate selection was ambiguous and the selected + * dependency identifier, if available. + */ + private function selectCompositeDependency (string $consumer, ParameterPlan $parameter, array $path, array &$issues):array { + + $type = $parameter->composite; + + if ($type === null) + return [false, null]; + + try { + + $dependency = match (true) { + + $type instanceof ReflectionUnionType => $this->selector->union( + $parameter->parameter, + $type + ), + + $type instanceof ReflectionIntersectionType => $this->selector->intersection( // @phpstan-ignore instanceof.alwaysTrue + $parameter->parameter, + $type + ) + + }; + + return [false, $dependency]; + + } catch (AmbiguousDependencyException) { + + $name = $parameter->parameter->getName(); + + $issues[] = new Issue( + IssueType::AMBIGUOUS_DEPENDENCY, + Severity::ERROR, + $consumer, + "Multiple implementations are eligible for composite parameter '$name' of '$consumer'.", + $path + ); + + return [true, null]; + + } + + } + + /** + * ### Inspects tagged constructor dependencies + * + * Recursively validates all services associated with the specified tag. + * + * Tagged service identifiers are retrieved in registration order using the shared tag registry. Each service is + * inspected within the active dependency path to detect missing dependencies, invalid dependency plans, and circular + * constructor dependencies. + * + * An unknown or empty tag produces no dependency validation issues because tagged injection permits empty + * collections. + * + * Service instances are not constructed, and factory callbacks are never executed during validation. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers. + * @uses \FireHub\Container\Registry::has() To verify registered service identifiers. + * @uses \FireHub\Container\Validation\DependencyGraph::inspect() To recursively validate tagged dependencies. + * @uses \FireHub\Runtime\Arr\Inspection::count() To determine the number of tagged services. + * + * @param non-empty-string $tag

+ * Tag identifying the registered services. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported_cycles

+ * Previously reported dependency cycle signatures. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the union contains an unsupported + * intersection member. + * + * @return void + */ + private function inspectTaggedDependencies (string $tag, array $path, array &$issues, array &$reported_cycles):void { + + foreach ($this->tags->get($tag) as $dependency) { + + if (!$this->registry->has($dependency)) { + + $issues[] = new Issue( + IssueType::MISSING_DEPENDENCY, + Severity::ERROR, + $path[Runtime\Arr\Inspection::count($path) - 1], // @phpstan-ignore offsetAccess.notFound + "Tagged dependency '$dependency' associated with tag '$tag' is not registered.", + [...$path, $dependency] + ); + + continue; + + } + + $this->inspect( + $dependency, + $path, + $issues, + $reported_cycles + ); + + } + + } + + /** + * ### Inspects collection constructor dependencies + * + * Recursively validates registered services matching the requested collection element type. + * + * Service identifiers are resolved to their canonical targets before candidate selection. Duplicate canonical + * targets are ignored, preserving the same collection membership rules used by runtime resolution. + * + * Matching services are inspected without constructing instances or executing factory callbacks. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::all() To retrieve registered service definitions. + * @uses \FireHub\Container\Validation\DependencyGraph::canonicalId() To resolve canonical service identifiers. + * @uses \FireHub\Container\Validation\DependencyGraph::matchesCollectionType() To check collection compatibility. + * @uses \FireHub\Container\Validation\DependencyGraph::inspect() To recursively validate matching dependencies. + * + * @param class-string $type

+ * Required collection element type. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported_cycles

+ * Previously reported dependency cycle signatures. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the union contains an unsupported + * intersection member. + * + * @return void + */ + private function inspectCollectionDependencies (string $type, array $path, array &$issues, array &$reported_cycles):void { + + /** @var array $resolved */ + $resolved = []; + + foreach ($this->registry->all() as $id => $definition) { + + $target = $this->canonicalId($id); + + if ($target === null) continue; + + if (isset($resolved[$target])) continue; + + if (!$this->matchesCollectionType($id, $target, $type)) continue; + + $this->inspect( + $id, + $path, + $issues, + $reported_cycles + ); + + $resolved[$target] = true; + + } + + } + + /** + * ### Checks collection candidate compatibility + * + * Determines whether a registered service satisfies the requested collection element type. + * + * Compatibility is evaluated against the original service identifier, its canonical identifier, and the statically + * known implementation type. + * + * Factory definitions without a statically known implementation are matched only through their service identifiers. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TypeMatcher::matches() To check type compatibility. + * @uses \FireHub\Container\Registry::find() To retrieve canonical service definitions. + * + * @param class-string $id

+ * Registered service identifier. + *

+ * @param class-string $target

+ * Canonical service identifier. + *

+ * @param class-string $type

+ * Required collection element type. + *

+ * + * @return bool True if the registered service matches the collection element type. + */ + private function matchesCollectionType (string $id, string $target, string $type):bool { + + if ($this->matcher->matches($id, $type)) + return true; + + if ($this->matcher->matches($target, $type)) + return true; + + $definition = $this->registry->find($target); + + return match (true) { + $definition instanceof Cls => $this->matcher->matches($definition->class, $type), + $definition instanceof Instance => $definition->instance instanceof $type, + default => false + }; + + } + +} \ No newline at end of file diff --git a/src/Validation/Issue.php b/src/Validation/Issue.php new file mode 100644 index 0000000..1244541 --- /dev/null +++ b/src/Validation/Issue.php @@ -0,0 +1,83 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Validation; + +/** + * ### Represents a container validation issue + * + * Describes a configuration problem or warning discovered during static analysis of service definitions. + * + * Validation issues are immutable and contain enough diagnostic information to identify the affected service and + * dependency path. + * @since 1.0.0 + * + * @phpstan-type DependencyPath list + */ +final readonly class Issue { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Validation\IssueType $type

+ * Category of the validation issue. + *

+ * @param \FireHub\Container\Validation\Severity $severity

+ * Severity of the validation issue. + *

+ * @param non-empty-string $service

+ * Identifier of the affected service. + *

+ * @param non-empty-string $message

+ * Human-readable diagnostic message. + *

+ * @param list $path [optional]

+ * Dependency path associated with the issue. + *

+ * + * @return void + */ + public function __construct ( + public IssueType $type, + public Severity $severity, + public string $service, + public string $message, + public array $path = [] + ) {} + + /** + * ### Checks whether the issue is an error + * @since 1.0.0 + * + * @return bool True if the issue represents an error. + */ + public function isError ():bool { + + return $this->severity === Severity::ERROR; + + } + + /** + * ### Checks whether the issue is a warning + * @since 1.0.0 + * + * @return bool True if the issue represents a warning. + */ + public function isWarning ():bool { + + return $this->severity === Severity::WARNING; + + } + +} \ No newline at end of file diff --git a/src/Validation/IssueType.php b/src/Validation/IssueType.php new file mode 100644 index 0000000..f12f6d1 --- /dev/null +++ b/src/Validation/IssueType.php @@ -0,0 +1,162 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Validation; + +/** + * ### Defines container validation issue types + * + * Categorizes configuration and dependency problems discovered during static validation of dependency injection + * service definitions. + * + * Validation issue types identify the nature of a detected problem independently of its severity, affected service, + * diagnostic message, or dependency path. + * + * The validation process does not instantiate services, execute factories, or modify container configuration. + * + * Some issues represent confirmed configuration errors, while others indicate limitations of static analysis. + * Their severity is determined separately through ValidationSeverity. + * @since 1.0.0 + */ +enum IssueType { + + /** + * ### Missing class + * + * A referenced class, interface, or other required type cannot be found. + * @since 1.0.0 + */ + case MISSING_CLASS; + + /** + * ### Non-instantiable class + * + * A class-based service definition references a type that cannot be instantiated directly, such as an abstract + * class or interface, without a suitable concrete implementation. + * @since 1.0.0 + */ + case NON_INSTANTIABLE_CLASS; + + /** + * ### Type mismatch + * + * A service definition or binding references an implementation that is incompatible with its declared service + * type. + * @since 1.0.0 + */ + case TYPE_MISMATCH; + + /** + * ### Missing dependency + * + * A required dependency cannot be satisfied through registered bindings, explicit parameter bindings, + * constructor defaults, or supported automatic resolution. + * @since 1.0.0 + */ + case MISSING_DEPENDENCY; + + /** + * ### Ambiguous dependency + * + * Multiple dependency resolution candidates satisfy a required type, and the available configuration does not + * identify a unique implementation. + * + * This includes ambiguity involving union, intersection, and disjunctive normal form types. + * @since 1.0.0 + */ + case AMBIGUOUS_DEPENDENCY; + + /** + * ### Circular dependency + * + * A dependency graph contains a cycle that prevents the involved services from being constructed through + * ordinary dependency resolution. + * @since 1.0.0 + */ + case CIRCULAR_DEPENDENCY; + + /** + * ### Invalid alias + * + * An alias references a target that cannot be resolved according to the container's alias resolution rules. + * @since 1.0.0 + */ + case INVALID_ALIAS; + + /** + * ### Circular alias + * + * Two or more service aliases form a resolution cycle without reaching a concrete service definition. + * @since 1.0.0 + */ + case CIRCULAR_ALIAS; + + /** + * ### Invalid parameter binding + * + * An explicit constructor parameter binding references an unknown parameter or provides a value that is + * incompatible with the parameter's declared type. + * @since 1.0.0 + */ + case INVALID_PARAMETER_BINDING; + + /** + * ### Invalid contextual binding + * + * A contextual dependency binding references an invalid consumer, dependency, or implementation, or defines + * a relationship incompatible with the dependency's declared type. + * @since 1.0.0 + */ + case INVALID_CONTEXTUAL_BINDING; + + /** + * ### Lifetime mismatch + * + * A service dependency relationship violates the container's lifetime rules. + * + * Lifetime compatibility must be evaluated according to the actual resolution and ownership semantics of + * the container rather than assuming that every dependency between different lifetimes is invalid. + * @since 1.0.0 + */ + case LIFETIME_MISMATCH; + + /** + * ### Unverifiable factory + * + * A factory-based service definition cannot be fully validated without executing its factory callback. + * + * This issue indicates a limitation of static analysis rather than a confirmed configuration error. + * @since 1.0.0 + */ + case UNVERIFIABLE_FACTORY; + + /** + * ### Invalid dependency plan + * + * Constructor dependency metadata cannot be compiled because of invalid or unsupported injection configuration. + * @since 1.0.0 + */ + case INVALID_DEPENDENCY_PLAN; + + /** + * ### Unused contextual binding + * + * A contextual dependency binding is structurally valid but cannot participate in constructor dependency resolution + * because its dependency contract is not referenced by an eligible constructor parameter. + * + * This includes bindings shadowed by higher-priority explicit parameter bindings or collection injection attributes. + * @since 1.0.0 + */ + case UNUSED_CONTEXTUAL_BINDING; + +} \ No newline at end of file diff --git a/src/Validation/Lifetime.php b/src/Validation/Lifetime.php new file mode 100644 index 0000000..8031c8b --- /dev/null +++ b/src/Validation/Lifetime.php @@ -0,0 +1,573 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Validation; + +use FireHub\Core\Meta\Enum\Container\Lifetime as LifetimeService; +use FireHub\Container\Registry; +use FireHub\Container\Definition\ { + Cls, Factory, Instance +}; +use FireHub\Container\Internal\ { + CandidateSelector, CanonicalIdCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlanCache, + ParameterBindingRegistry, ParameterPlan, TagRegistry, TypeMatcher +}; +use FireHub\Container\Exception\ { + AmbiguousDependencyException, ServiceInstantiationException +}; +use FireHub\Runtime; +use ReflectionIntersectionType, ReflectionUnionType; + +/** + * ### Validates service lifetime relationships + * + * Performs static analysis of constructor dependency graphs to detect singleton services that capture scoped + * dependencies. + * + * Supports direct and transitive dependency relationships without constructing services or executing factories. + * + * Explicit constructor parameter bindings are excluded because their values are supplied independently of + * automatic dependency resolution. + * + * This initial implementation analyzes named constructor dependencies. Composite, contextual, tagged, and + * collection dependencies are reserved for later validation stages. + * @since 1.0.0 + * + * @internal + */ +final readonly class Lifetime { + + /** + * ### Compiled dependency plan cache + * @since 1.0.0 + */ + private DependencyPlanCache $plans; + + /** + * ### Dependency type matcher + * @since 1.0.0 + */ + private TypeMatcher $matcher; + + /** + * ### Contextual dependency candidate selector + * @since 1.0.0 + */ + private ContextualCandidateSelector $contextual_selector; + + /** + * ### Composite dependency candidate selector + * @since 1.0.0 + */ + private CandidateSelector $selector; + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Registry $registry

+ * Registered service definitions. + *

+ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

+ * Explicit constructor parameter bindings. + *

+ * @param \FireHub\Container\Internal\ContextualBindingRegistry $contextual

+ * Contextual dependency binding registry. + *

+ * @param \FireHub\Container\Internal\TagRegistry $tags

+ * Registered service tags. + *

+ * @param \FireHub\Container\Internal\DependencyPlanCache $plans

+ * Compiled dependency plans. + *

+ * @param \FireHub\Container\Internal\TypeMatcher $matcher

+ * Dependency type matcher. + *

+ * @param \FireHub\Container\Internal\CanonicalIdCache $canonical

+ * Canonical service identifier cache. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry, + private ParameterBindingRegistry $bindings, + ContextualBindingRegistry $contextual, + private TagRegistry $tags, + DependencyPlanCache $plans, + TypeMatcher $matcher, + private CanonicalIdCache $canonical + ) { + + $this->plans = $plans; + + $this->matcher = $matcher; + + $this->contextual_selector = new ContextualCandidateSelector( + $contextual + ); + + $this->selector = new CandidateSelector( + $this->matcher, + fn (string $id):bool => $this->registry->has($id), + fn ():iterable => $this->registry->implementations() + ); + + } + + /** + * ### Validates registered service lifetime relationships + * + * Traverses singleton dependency graphs and reports scoped dependencies captured directly or transitively. + * + * Each singleton is analyzed independently, and repeated dependency paths are deduplicated. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\Lifetime::inspect() To validate dependency paths. + * @uses \FireHub\Container\Registry::all() To retrieve all registered service definitions. + * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the composite type is unsupported. + * + * @return list<\FireHub\Container\Validation\Issue> Detected lifetime issues. + */ + public function validate ():array { + + $issues = []; $reported = []; + foreach ($this->registry->all() as $id => $definition) { + + if (!$definition instanceof Cls) continue; + + if ($definition->lifetime() !== LifetimeService::SINGLETON) continue; + + $this->inspect( + $id, + $id, + [], + $issues, + $reported + ); + + } + + return $issues; + + } + + /** + * ### Inspects a singleton dependency path + * + * Traverses statically resolvable constructor dependencies and reports scoped services encountered along + * the active path. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\Lifetime::canonicalId() To resolve a canonical service identifier. + * @uses \FireHub\Container\Registry::find() To retrieve a service definition. + * @uses \FireHub\Container\Definition\Cls::lifetime() To check the service lifetime. + * @uses \FireHub\Container\Internal\TypeMatcher::isConstructible() To check if a class is constructible. + * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve a dependency plan. + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check if a parameter is explicitly bound. + * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if a service identifier is already in the path. + * @uses \FireHub\Container\Validation\Lifetime::inspectTagged() To recursively validate tagged dependencies. + * @uses \FireHub\Container\Validation\Lifetime::inspectCollection() To recursively validate collection dependencies. + * @uses \FireHub\Container\Validation\Lifetime::selectComposite() To select composite dependencies. + + * + * @param class-string $root

+ * Root singleton service identifier. + *

+ * @param class-string $id

+ * Current dependency identifier. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported

+ * Previously reported lifetime relationships. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the composite type is unsupported. + * + * @return void + */ + private function inspect (string $root, string $id, array $path, array &$issues, array &$reported):void { + + $id = $this->canonicalId($id); + + if ($id === null) return; + + if (Runtime\Arr\Inspection::inArray($path, $id)) return; + + $path[] = $id; + + $definition = $this->registry->find($id); + + if ($definition instanceof Cls) { + + if ($definition->lifetime() === LifetimeService::SCOPED) { + + $key = $root . "\0" . $id; + + if (!isset($reported[$key])) { + + $reported[$key] = true; + + $issues[] = new Issue( + IssueType::LIFETIME_MISMATCH, + Severity::ERROR, + $root, + "Singleton service '$root' captures scoped dependency '$id'.", + $path + ); + + } + + return; + + } + + $class = $definition->class; + + } elseif ($definition instanceof Instance || $definition instanceof Factory) { + + return; + + } else { + + $class = $id; + + } + + if (!$this->matcher->isConstructible($class)) + return; + + try { + + $plan = $this->plans->get($class); + + } catch (ServiceInstantiationException) { + + return; + + } + + foreach ($plan->parameters as $parameter) { + + $name = $parameter->parameter->getName(); + + if ($this->bindings->has($class, $name)) + continue; + + if ($parameter->tag !== null) { + + $this->inspectTagged( + $root, + $parameter->tag, + $path, + $issues, + $reported + ); + + continue; + + } + + if ($parameter->collection !== null) { + + $this->inspectCollection( + $root, + $parameter->collection, + $path, + $issues, + $reported + ); + + continue; + + } + + try { + + $dependency = $this->contextual_selector->select( + $class, + $parameter + ); + + } catch (AmbiguousDependencyException) { + + continue; + + } + + if ($dependency === null) { + + if ($parameter->composite !== null) { + + try { + + $dependency = $this->selectComposite($parameter); + + } catch (AmbiguousDependencyException) { + + continue; + + } + + } else { + + $dependency = $parameter->id; + + } + + } + + if ($dependency === null) continue; + + if ( + !$this->registry->has($dependency) + && !$this->matcher->isConstructible($dependency) + ) continue; + + $this->inspect( + $root, + $dependency, + $path, + $issues, + $reported + ); + + } + + } + + /** + * ### Resolves a canonical service identifier + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\Canonical::get() To retrieve the canonical identifier. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @return null|class-string Canonical service identifier. + */ + private function canonicalId (string $id):?string { + + return $this->canonical->get($id); + + } + + /** + * ### Selects a composite dependency + * + * Resolves a unique service candidate for union, intersection, and DNF constructor parameter types. + * + * Candidate selection follows the same rules as runtime dependency resolution. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\CandidateSelector::union() To select union and DNF dependencies. + * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select intersection dependencies. + * + * @param \FireHub\Container\Internal\ParameterPlan $parameter

+ * Compiled constructor parameter. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the composite type is unsupported. + * + * @return null|class-string Selected dependency identifier. + */ + private function selectComposite (ParameterPlan $parameter):?string { + + $type = $parameter->composite; + + if ($type === null) + return null; + + if ($type instanceof ReflectionUnionType) + return $this->selector->union( + $parameter->parameter, + $type + ); + + if ($type instanceof ReflectionIntersectionType) // @phpstan-ignore instanceof.alwaysTrue + return $this->selector->intersection( + $parameter->parameter, + $type + ); + + return null; // @phpstan-ignore deadCode.unreachable + + } + + /** + * ### Inspects tagged dependency lifetimes + * + * Traverses registered services associated with a tag and validates their lifetime relationships. + * + * Missing services are ignored because dependency graph validation reports them separately. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged services. + * @uses \FireHub\Container\Registry::has() To check if a tagged service exists. + * @uses \FireHub\Container\Validation\Lifetime::inspect() To inspect dependency lifetimes. + * + * @param class-string $root

+ * Root singleton service identifier. + *

+ * @param non-empty-string $tag

+ * Service tag. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported

+ * Previously reported lifetime relationships. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the composite type is unsupported. + * + * @return void + */ + private function inspectTagged (string $root, string $tag, array $path, array &$issues, array &$reported):void { + + foreach ($this->tags->get($tag) as $dependency) { + + if (!$this->registry->has($dependency)) + continue; + + $this->inspect( + $root, + $dependency, + $path, + $issues, + $reported + ); + + } + + } + + /** + * ### Inspects collection dependency lifetimes + * + * Traverses all registered services compatible with the requested collection element type. + * + * Alias targets are deduplicated, and factory callbacks are never executed. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::all() To retrieve registered services. + * @uses \FireHub\Container\Validation\Lifetime::canonicalId() To resolve aliases. + * @uses \FireHub\Container\Validation\Lifetime::matchesCollectionType() To check collection compatibility. + * @uses \FireHub\Container\Validation\Lifetime::inspect() To inspect dependency lifetimes. + * + * @param class-string $root

+ * Root singleton service identifier. + *

+ * @param class-string $type

+ * Requested collection element type. + *

+ * @param list $path

+ * Active dependency path. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param array $reported

+ * Previously reported lifetime relationships. + *

+ * + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the composite type is unsupported. + * + * @return void + */ + private function inspectCollection (string $root, string $type, array $path, array &$issues, array &$reported):void { + + /** @var array $resolved */ + $resolved = []; + + foreach ($this->registry->all() as $id => $definition) { + + $target = $this->canonicalId($id); + + if ($target === null) continue; + + if (isset($resolved[$target])) continue; + + if (!$this->matchesCollectionType($id, $target, $type)) continue; + + $resolved[$target] = true; + + $this->inspect( + $root, + $id, + $path, + $issues, + $reported + ); + + } + + } + + /** + * ### Checks collection candidate compatibility + * + * Matches registered service identifiers, canonical identifiers, and known implementation types against a + * requested collection element contract. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\TypeMatcher::matches() To check type compatibility. + * @uses \FireHub\Container\Registry::find() To retrieve registered definitions. + * + * @param class-string $id

+ * Original service identifier. + *

+ * @param class-string $target

+ * Canonical service identifier. + *

+ * @param class-string $type

+ * Requested collection element type. + *

+ * + * @return bool True if the service matches the collection element type. + */ + private function matchesCollectionType (string $id, string $target, string $type):bool { + + if ($this->matcher->matches($id, $type)) + return true; + + if ($this->matcher->matches($target, $type)) + return true; + + $definition = $this->registry->find($target); + + return match (true) { + $definition instanceof Cls => $this->matcher->matches($definition->class, $type), + $definition instanceof Instance => $definition->instance instanceof $type, + default => false + }; + + } + +} \ No newline at end of file diff --git a/src/Validation/ParameterBinding.php b/src/Validation/ParameterBinding.php new file mode 100644 index 0000000..eb57136 --- /dev/null +++ b/src/Validation/ParameterBinding.php @@ -0,0 +1,262 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Validation; + +use FireHub\Container\Internal\ParameterBindingRegistry; +use ReflectionClass, ReflectionException, ReflectionIntersectionType, ReflectionNamedType, ReflectionParameter, + ReflectionType, ReflectionUnionType; + +/** + * ### Validates explicit constructor parameter bindings + * + * Performs static validation of configured constructor parameter values without instantiating services. + * + * Validates parameter existence and compatibility with declared PHP parameter types, including nullable, union, + * intersection, and disjunctive normal form types. + * + * Validation does not modify bindings or execute application code. + * @since 1.0.0 + * + * @internal + */ +final readonly class ParameterBinding { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

+ * Registered constructor parameter bindings. + *

+ * + * @return void + */ + public function __construct ( + private ParameterBindingRegistry $bindings + ) {} + + /** + * ### Validates registered parameter bindings + * + * Checks that configured constructor parameters exist and their values satisfy the declared parameter types. + * @since 1.0.0 + * + * @uses \FireHub\Container\Internal\ParameterBindingRegistry::all() To retrieve registered bindings. + * @uses \FireHub\Container\Validation\ParameterBinding::findParameter() To find constructor parameters. + * @uses \FireHub\Container\Validation\ParameterBinding::matchesType() To check value compatibility. + * + * @return list<\FireHub\Container\Validation\Issue> Detected validation issues. + */ + public function validate ():array { + + $issues = []; + + foreach ($this->bindings->all() as $class => $bindings) { + + try { + + $reflection = new ReflectionClass($class); + + } catch (ReflectionException) { // @phpstan-ignore catch.neverThrown + + $issues[] = new Issue( + IssueType::INVALID_PARAMETER_BINDING, + Severity::ERROR, + $class, + "Parameter bindings reference an unknown class '$class'." + ); + + continue; + + } + + $constructor = $reflection->getConstructor(); + + foreach ($bindings as $name => $value) { + + $parameter = $constructor !== null + ? $this->findParameter($constructor->getParameters(), $name) + : null; + + if ($parameter === null) { + + $issues[] = new Issue( + IssueType::INVALID_PARAMETER_BINDING, + Severity::ERROR, + $class, + "Constructor parameter '$name' does not exist in '$class'." + ); + + continue; + + } + + $type = $parameter->getType(); + + if ( + $type !== null + && !$this->matchesType($parameter, $type, $value) + ) { + + $issues[] = new Issue( + IssueType::INVALID_PARAMETER_BINDING, + Severity::ERROR, + $class, + "Bound value for parameter '$name' of '$class' is incompatible with type '$type'." + ); + + } + + } + + } + + return $issues; + + } + + /** + * ### Finds a constructor parameter + * @since 1.0.0 + * + * @param list $parameters

+ * Constructor parameters. + *

+ * @param non-empty-string $name

+ * Parameter name. + *

+ * + * @return null|ReflectionParameter Matching parameter. + */ + private function findParameter (array $parameters, string $name):?ReflectionParameter { + + foreach ($parameters as $parameter) + if ($parameter->getName() === $name) + return $parameter; + + return null; + + } + + /** + * ### Checks parameter value compatibility + * + * Evaluates whether an explicitly bound value satisfies the declared PHP parameter type. + * + * Union types require at least one matching member, while intersection types require every member to match. + * Nested combinations are evaluated recursively to support disjunctive normal form types. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\ParameterBinding::matchesNamedType() To validate named types. + * + * @param \ReflectionParameter $parameter

+ * Constructor parameter. + *

+ * @param \ReflectionType $type

+ * Declared parameter type. + *

+ * @param mixed $value

+ * Explicitly bound parameter value. + *

+ * + * @return bool True if the value satisfies the declared type. + */ + private function matchesType (ReflectionParameter $parameter, ReflectionType $type, mixed $value):bool { + + if ($value === null) + return $type->allowsNull(); + + if ($type instanceof ReflectionUnionType) { + + foreach ($type->getTypes() as $member) + if ($this->matchesType($parameter, $member, $value)) + return true; + + return false; + + } + + if ($type instanceof ReflectionIntersectionType) { + + foreach ($type->getTypes() as $member) + if (!$this->matchesType($parameter, $member, $value)) + return false; + + return true; + + } + + return $type instanceof ReflectionNamedType + && $this->matchesNamedType($parameter, $type, $value); + + } + + /** + * ### Checks named parameter type compatibility + * + * Validates scalar, built-in, and class-based parameter types against an explicitly configured value. + * + * Relative class types are resolved against the class declaring the constructor parameter. + * @since 1.0.0 + * + * @param \ReflectionParameter $parameter

+ * Constructor parameter. + *

+ * @param \ReflectionNamedType $type

+ * Declared named type. + *

+ * @param mixed $value

+ * Explicitly bound parameter value. + *

+ * + * @return bool True if the value satisfies the named type. + */ + private function matchesNamedType (ReflectionParameter $parameter, ReflectionNamedType $type, mixed $value):bool { + + $name = $type->getName(); + + if (!$type->isBuiltin()) { + + $declaring_class = $parameter->getDeclaringClass(); + $parent = $declaring_class?->getParentClass(); + + $class = match ($name) { + 'self' => $declaring_class?->getName(), + 'parent' => $parent !== false ? $parent?->getName() : null, + default => $name + }; + + return $class !== null && $value instanceof $class; + + } + + return match ($name) { + 'array' => is_array($value), + 'bool' => is_bool($value), + 'callable' => is_callable($value), + 'false' => $value === false, + 'float' => is_float($value) || is_int($value), + 'int' => is_int($value), + 'iterable' => is_iterable($value), + 'mixed' => true, + 'null' => $value === null, + 'object' => is_object($value), + 'string' => is_string($value), + 'true' => $value === true, + default => false + }; + + } + +} \ No newline at end of file diff --git a/src/Validation/Result.php b/src/Validation/Result.php new file mode 100644 index 0000000..626f0c7 --- /dev/null +++ b/src/Validation/Result.php @@ -0,0 +1,173 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container + */ + +namespace FireHub\Container\Validation; + +use FireHub\Runtime; +use Countable, IteratorAggregate, Traversable; + +/** + * ### Represents the result of container validation + * + * Collects errors and warnings discovered while statically analyzing registered service definitions. + * + * A validation result is considered valid when it contains no errors. Warnings do not invalidate the result. + * + * The result is immutable and supports iteration over all issues. + * @since 1.0.0 + * + * @implements IteratorAggregate + */ +final readonly class Result implements Countable, IteratorAggregate { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param list<\FireHub\Container\Validation\Issue> $issues [optional]

+ * Validation issues. + *

+ */ + public function __construct ( + private array $issues = [] + ) {} + + /** + * ### Checks whether validation succeeded + * + * Validation succeeds when no errors are present. Warnings do not cause validation failure. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\Issue::isError() To check for error status. + * + * @return bool True if no errors were detected. + */ + public function isValid ():bool { + + foreach ($this->issues as $issue) { + + if ($issue->isError()) + return false; + + } + + return true; + + } + + /** + * ### Checks whether warnings are present + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\Issue::isWarning() To check for warning status. + * + * @return bool True if at least one warning exists. + */ + public function hasWarnings ():bool { + + foreach ($this->issues as $issue) { + + if ($issue->isWarning()) + return true; + + } + + return false; + + } + + /** + * ### Gets all validation issues + * @since 1.0.0 + * + * @return list<\FireHub\Container\Validation\Issue> Validation issues. + */ + public function issues ():array { + + return $this->issues; + + } + + /** + * ### Gets validation errors + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\Issue::isError() To check for error status. + * + * @return list<\FireHub\Container\Validation\Issue> Validation errors. + */ + public function errors ():array { + + $errors = []; + + foreach ($this->issues as $issue) { + + if ($issue->isError()) + $errors[] = $issue; + + } + + return $errors; + + } + + /** + * ### Gets validation warnings + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\Issue::isWarning() To check for warning status. + * + * @return list<\FireHub\Container\Validation\Issue> Validation warnings. + */ + public function warnings ():array { + + $warnings = []; + + foreach ($this->issues as $issue) { + + if ($issue->isWarning()) + $warnings[] = $issue; + + } + + return $warnings; + + } + + /** + * ### Counts validation issues + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Arr\Inspection::count() To count the number of issues. + * + * @return non-negative-int Number of validation issues. + */ + public function count ():int { + + return Runtime\Arr\Inspection::count($this->issues); + + } + + /** + * ### Iterates over validation issues + * @since 1.0.0 + * + * @return Traversable Validation issues. + */ + public function getIterator ():Traversable { + + yield from $this->issues; + + } + +} \ No newline at end of file diff --git a/src/Validation/Severity.php b/src/Validation/Severity.php new file mode 100644 index 0000000..147e7c1 --- /dev/null +++ b/src/Validation/Severity.php @@ -0,0 +1,41 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Container + */ + +namespace FireHub\Container\Validation; + +/** + * ### Defines container validation severity levels + * + * Distinguishes confirmed configuration errors from warnings that do not necessarily prevent successful service + * resolution. + * @since 1.0.0 + */ +enum Severity { + + /** + * ### Error + * + * Represents a confirmed configuration problem. + * @since 1.0.0 + */ + case ERROR; + + /** + * ### Warning + * + * Represents a potential problem or a limitation of static validation. + * @since 1.0.0 + */ + case WARNING; + +} \ No newline at end of file diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..7ab9176 --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,442 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Container + */ + +namespace FireHub\Container; + +use FireHub\Container\Validation\ { + ContextualBinding, DependencyGraph, Issue, IssueType, Lifetime, ParameterBinding, Result, Severity +}; +use FireHub\Container\Definition\ { + Alias, Cls, Factory, Instance +}; +use FireHub\Container\Internal\ { + CanonicalIdCache, ConstructorMetadataCache, ContextualBindingRegistry, DependencyPlanCache, + ParameterBindingRegistry, ParameterPlanCompiler, TagRegistry, TypeMatcher +}; +use FireHub\Runtime; +use ReflectionClass, ReflectionException; + +/** + * ### Validates container service definitions + * + * Performs static analysis of registered dependency injection service definitions without constructing services, + * resolving dependencies, or executing factory callbacks. + * + * The validator checks class-based definitions, existing instances, and alias chains for structural correctness and + * type compatibility. + * + * Factory definitions are reported as statically unverifiable because their execution behavior cannot be determined + * without invoking the factory callback. + * + * Validation does not modify the service registry or container lifecycle state. + * @since 1.0.0 + * + * @internal + */ +final readonly class Validator { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Registry $registry

+ * Registry containing service definitions. + *

+ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

+ * Explicit constructor parameter bindings. + *

+ * @param \FireHub\Container\Internal\ContextualBindingRegistry $contextual

+ * Contextual dependency binding registry. + *

+ * @param \FireHub\Container\Internal\TagRegistry $tags

+ * Registered service tags. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry, + private ParameterBindingRegistry $bindings, + private ContextualBindingRegistry $contextual, + private TagRegistry $tags + ) {} + + /** + * ### Validates registered service definitions + * + * Inspects registered service definitions and collects all independently detectable structural problems. + * + * Constructor dependencies and service lifetime relationships are not analyzed at this stage. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validation\DependencyGraph::validate() To validate dependency graphs. + * @uses \FireHub\Container\Internal\ParameterBinding::validate() To validate parameter bindings. + * @uses \FireHub\Container\Internal\ContextualBinding::validate() To validate contextual bindings. + * @uses \FireHub\Container\Internal\Lifetime::validate() To validate service lifetimes. + * @uses \FireHub\Container\Registry::all() To retrieve all registered service definitions. + * @uses \FireHub\Container\Validator::validateClass() To validate class-based definitions. + * @uses \FireHub\Container\Validator::validateInstance() To validate existing instance definitions. + * @uses \FireHub\Container\Validator::validateAlias() To validate alias chains. + * @uses \FireHub\Runtime\Arr\Mutation::push() To append validation issues. + * + * @throws \FireHub\Container\Exception\ServiceInstantiationException If a constructor parameter contains invalid + * injection attributes or unsupported dependency metadata. + * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible. + * @throws \FireHub\Container\Exception\ServiceInstantiationException If the composite type is unsupported. + * + * @return \FireHub\Container\Validation\Result Validation result. + */ + public function validate ():Result { + + $metadata = new ConstructorMetadataCache; + + $matcher = new TypeMatcher($metadata); + + $plans = new DependencyPlanCache( + $metadata, + new ParameterPlanCompiler($matcher) + ); + + $issues = []; + + foreach ($this->registry->all() as $id => $definition) { + + if ($definition instanceof Cls) { + + $this->validateClass($id, $definition, $issues); + + } elseif ($definition instanceof Instance) { + + $this->validateInstance($id, $definition, $issues); + + } elseif ($definition instanceof Alias) { + + $this->validateAlias($id, $issues); + + } elseif ($definition instanceof Factory) { + + $issues[] = new Issue( + IssueType::UNVERIFIABLE_FACTORY, + Severity::WARNING, + $id, + 'Factory execution cannot be statically validated.' + ); + + } + + } + + $canonical = new CanonicalIdCache( + $this->registry + ); + + $dependency_issues = new DependencyGraph( + $this->registry, + $this->bindings, + $this->contextual, + $this->tags, + $plans, + $matcher, + $canonical + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$dependency_issues); + + $parameter_issues = new ParameterBinding( + $this->bindings + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$parameter_issues); + + $contextual_issues = new ContextualBinding( + $this->registry, + $this->contextual, + $this->bindings, + $plans + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$contextual_issues); + + $lifetime_issues = new Lifetime( + $this->registry, + $this->bindings, + $this->contextual, + $this->tags, + $plans, + $matcher, + $canonical + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$lifetime_issues); + + /** @var list<\FireHub\Container\Validation\Issue> $issues */ + return new Result($issues); + + } + + /** + * ### Validates a class-based service definition + * + * Verifies that the implementation exists, is instantiable, and is compatible with the registered service + * identifier when the identifier represents an existing PHP class or interface. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validator::validateTypeCompatibility() To check type compatibility. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check if the identifier is a class. + * + * @param class-string $id

+ * Registered service identifier. + *

+ * @param \FireHub\Container\Definition\Cls $definition

+ * Class-based service definition. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * + * @return void + */ + private function validateClass (string $id, Cls $definition, array &$issues):void { + + $class = $definition->class; + + if (!Runtime\ObjectModel\Inspection::isClass($class)) { + + $issues[] = new Issue( + IssueType::MISSING_CLASS, + Severity::ERROR, + $id, + "Implementation class '$class' does not exist." + ); + + return; + + } + + try { + + $reflection = new ReflectionClass($class); + + } catch (ReflectionException) { // @phpstan-ignore catch.neverThrown + + $issues[] = new Issue( + IssueType::MISSING_CLASS, + Severity::ERROR, + $id, + "Implementation class '$class' cannot be reflected." + ); + + return; + + } + + if (!$reflection->isInstantiable()) { + + $issues[] = new Issue( + IssueType::NON_INSTANTIABLE_CLASS, + Severity::ERROR, + $id, + "Implementation class '$class' is not instantiable." + ); + + } + + $this->validateTypeCompatibility( + $id, + $class, + $issues + ); + + } + + /** + * ### Validates an existing service instance + * + * Verifies that the registered object is compatible with its service identifier when the identifier + * represents an existing PHP class or interface. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validator::validateTypeCompatibility() To check type compatibility. + * + * @param class-string $id

+ * Registered service identifier. + *

+ * @param \FireHub\Container\Definition\Instance $definition

+ * Existing instance definition. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * + * @return void + */ + private function validateInstance (string $id, Instance $definition, array &$issues):void { + + $this->validateTypeCompatibility( + $id, + $definition->instance::class, + $issues + ); + + } + + /** + * ### Validates a service alias + * + * Traverses the alias chain to detect missing targets, circular references, and incompatible terminal + * implementations. + * + * Alias traversal does not resolve or instantiate services. + * @since 1.0.0 + * + * @uses \FireHub\Container\Validator::validateTypeCompatibility() To check type compatibility. + * @uses \FireHub\Container\Registry::find() To retrieve the target service definition. + * @uses \FireHub\Runtime\Str\SB\Delimiter::implode() To join path segments. + * + * @param class-string $id

+ * Alias service identifier. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * + * @return void + */ + private function validateAlias (string $id, array &$issues):void { + + $visited = []; + $path = []; + + $current = $id; + + while (true) { + + if (isset($visited[$current])) { + + $path[] = $current; + + $issues[] = new Issue( + IssueType::CIRCULAR_ALIAS, + Severity::ERROR, + $id, + 'Circular alias chain detected: ' . Runtime\Str\SB\Delimiter::implode($path, ' -> '), + $path + ); + + return; + + } + + $visited[$current] = true; + $path[] = $current; + + $definition = $this->registry->find($current); + + if ($definition === null) { + + $issues[] = new Issue( + IssueType::INVALID_ALIAS, + Severity::ERROR, + $id, + "Alias target '$current' is not registered.", + $path + ); + + return; + + } + + if ($definition instanceof Alias) { + + $current = $definition->target; + + continue; + + } + + if ($definition instanceof Cls) { + + $this->validateTypeCompatibility( + $id, + $definition->class, + $issues, + $path + ); + + } elseif ($definition instanceof Instance) { + + $this->validateTypeCompatibility( + $id, + $definition->instance::class, + $issues, + $path + ); + + } + + return; + + } + + } + + /** + * ### Validates service type compatibility + * + * Checks whether the implementation satisfies the service identifier when the identifier represents an + * existing class or interface. + * + * Identifiers that do not represent loadable PHP types are not checked for type compatibility. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check if the identifier is a class. + * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check if the identifier is an interface. + * @uses \FireHub\Runtime\ObjectModel\Relation::instanceOf() To check if the implementation satisfies the contract. + * + * @param class-string $id

+ * Registered service identifier. + *

+ * @param class-string $implementation

+ * Implementation class. + *

+ * @param list<\FireHub\Container\Validation\Issue> $issues

+ * Collected validation issues. + *

+ * @param list $path [optional]

+ * Dependency or alias path associated with the issue. + *

+ * + * @return void + */ + private function validateTypeCompatibility (string $id, string $implementation, array &$issues, array $path = []):void { + + if ( + !Runtime\ObjectModel\Inspection::isClass($id) + && !Runtime\ObjectModel\Inspection::isInterface($id) + ) return; + + if ( + Runtime\ObjectModel\Relation::instanceOf($implementation, $id) + ) return; + + $issues[] = new Issue( + IssueType::TYPE_MISMATCH, + Severity::ERROR, + $id, + "Implementation '$implementation' is not compatible with service '$id'.", + $path + ); + + } + +} \ No newline at end of file diff --git a/tests/Stubs/CollectionValidationCircular.php b/tests/Stubs/CollectionValidationCircular.php new file mode 100644 index 0000000..be58615 --- /dev/null +++ b/tests/Stubs/CollectionValidationCircular.php @@ -0,0 +1,35 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * ### Circular collection validation consumer + * @since 1.0.0 + */ +final readonly class CollectionValidationCircular implements CollectionValidationContract { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param list $services Collection services. + */ + public function __construct ( + #[InjectAll(CollectionValidationContract::class)] + public array $services + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/CollectionValidationConsumer.php b/tests/Stubs/CollectionValidationConsumer.php new file mode 100644 index 0000000..efe5f09 --- /dev/null +++ b/tests/Stubs/CollectionValidationConsumer.php @@ -0,0 +1,35 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\InjectAll; + +/** + * ### Consumer of collection validation services + * @since 1.0.0 + */ +final readonly class CollectionValidationConsumer { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param list $services Collection services. + */ + public function __construct( + #[InjectAll(CollectionValidationContract::class)] + public array $services + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/CollectionValidationContract.php b/tests/Stubs/CollectionValidationContract.php new file mode 100644 index 0000000..99d9ac4 --- /dev/null +++ b/tests/Stubs/CollectionValidationContract.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Collection validation contract + * @since 1.0.0 + */ +interface CollectionValidationContract {} \ No newline at end of file diff --git a/tests/Stubs/CollectionValidationFirst.php b/tests/Stubs/CollectionValidationFirst.php new file mode 100644 index 0000000..c2b8b34 --- /dev/null +++ b/tests/Stubs/CollectionValidationFirst.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### First collection validation implementation + * @since 1.0.0 + */ +final readonly class CollectionValidationFirst implements CollectionValidationContract {} \ No newline at end of file diff --git a/tests/Stubs/CollectionValidationMissingContract.php b/tests/Stubs/CollectionValidationMissingContract.php new file mode 100644 index 0000000..8c295f1 --- /dev/null +++ b/tests/Stubs/CollectionValidationMissingContract.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Missing collection dependency contract + * @since 1.0.0 + */ +interface CollectionValidationMissingContract {} diff --git a/tests/Stubs/CollectionValidationMissingDependency.php b/tests/Stubs/CollectionValidationMissingDependency.php new file mode 100644 index 0000000..0b94aa7 --- /dev/null +++ b/tests/Stubs/CollectionValidationMissingDependency.php @@ -0,0 +1,32 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Collection member with an unresolved dependency + * @since 1.0.0 + */ +final readonly class CollectionValidationMissingDependency implements CollectionValidationContract { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param CollectionValidationMissingContract $dependency Missing dependency. + */ + public function __construct ( + public CollectionValidationMissingContract $dependency + ) {} + +} diff --git a/tests/Stubs/CollectionValidationNested.php b/tests/Stubs/CollectionValidationNested.php new file mode 100644 index 0000000..69b48b0 --- /dev/null +++ b/tests/Stubs/CollectionValidationNested.php @@ -0,0 +1,30 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Nested collection validation consumer + * @since 1.0.0 + */ +final readonly class CollectionValidationNested { + + /** + * ### Constructor + * @since 1.0.0 + */ + public function __construct ( + public CollectionValidationConsumer $consumer + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/CollectionValidationSecond.php b/tests/Stubs/CollectionValidationSecond.php new file mode 100644 index 0000000..a1b77fd --- /dev/null +++ b/tests/Stubs/CollectionValidationSecond.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Second collection validation implementation + * @since 1.0.0 + */ +final readonly class CollectionValidationSecond implements CollectionValidationContract {} \ No newline at end of file diff --git a/tests/Stubs/ContextualValidationOtherContract.php b/tests/Stubs/ContextualValidationOtherContract.php new file mode 100644 index 0000000..42de1d6 --- /dev/null +++ b/tests/Stubs/ContextualValidationOtherContract.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Contextual validation another contract + * @since 1.0.0 + */ +interface ContextualValidationOtherContract {} \ No newline at end of file diff --git a/tests/Stubs/ContextualValidationUnusedConsumer.php b/tests/Stubs/ContextualValidationUnusedConsumer.php new file mode 100644 index 0000000..9429bd4 --- /dev/null +++ b/tests/Stubs/ContextualValidationUnusedConsumer.php @@ -0,0 +1,26 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Contextual validation of unused consumer + * @since 1.0.0 + */ +final readonly class ContextualValidationUnusedConsumer { + + public function __construct ( + public string $name = 'default' + ) {} + +} diff --git a/tests/Stubs/LifetimeValidationExplicitConsumer.php b/tests/Stubs/LifetimeValidationExplicitConsumer.php new file mode 100644 index 0000000..d3eb955 --- /dev/null +++ b/tests/Stubs/LifetimeValidationExplicitConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationExplicitConsumer { + + public function __construct ( + public LifetimeValidationScoped $dependency + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/LifetimeValidationFactoryConsumer.php b/tests/Stubs/LifetimeValidationFactoryConsumer.php new file mode 100644 index 0000000..11f7f4f --- /dev/null +++ b/tests/Stubs/LifetimeValidationFactoryConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationFactoryConsumer { + + public function __construct ( + public LifetimeValidationScoped $dependency + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/LifetimeValidationNestedSingleton.php b/tests/Stubs/LifetimeValidationNestedSingleton.php new file mode 100644 index 0000000..589c24e --- /dev/null +++ b/tests/Stubs/LifetimeValidationNestedSingleton.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationNestedSingleton { + + public function __construct ( + public LifetimeValidationTransient $dependency + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/LifetimeValidationSafeConsumer.php b/tests/Stubs/LifetimeValidationSafeConsumer.php new file mode 100644 index 0000000..d25d176 --- /dev/null +++ b/tests/Stubs/LifetimeValidationSafeConsumer.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationSafeConsumer { + + public function __construct ( + public LifetimeValidationSafeSingleton $dependency + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/LifetimeValidationSafeSingleton.php b/tests/Stubs/LifetimeValidationSafeSingleton.php new file mode 100644 index 0000000..02b00da --- /dev/null +++ b/tests/Stubs/LifetimeValidationSafeSingleton.php @@ -0,0 +1,21 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationSafeSingleton { + +} \ No newline at end of file diff --git a/tests/Stubs/LifetimeValidationScoped.php b/tests/Stubs/LifetimeValidationScoped.php new file mode 100644 index 0000000..c528ea7 --- /dev/null +++ b/tests/Stubs/LifetimeValidationScoped.php @@ -0,0 +1,21 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationScoped { + +} \ No newline at end of file diff --git a/tests/Stubs/LifetimeValidationSingleton.php b/tests/Stubs/LifetimeValidationSingleton.php new file mode 100644 index 0000000..4271d17 --- /dev/null +++ b/tests/Stubs/LifetimeValidationSingleton.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationSingleton { + + public function __construct ( + public LifetimeValidationScoped $dependency + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/LifetimeValidationTransient.php b/tests/Stubs/LifetimeValidationTransient.php new file mode 100644 index 0000000..d5c6f12 --- /dev/null +++ b/tests/Stubs/LifetimeValidationTransient.php @@ -0,0 +1,25 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final readonly class LifetimeValidationTransient { + + public function __construct ( + public LifetimeValidationScoped $dependency + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/ParameterBindingValidationComposite.php b/tests/Stubs/ParameterBindingValidationComposite.php new file mode 100644 index 0000000..0a2dd86 --- /dev/null +++ b/tests/Stubs/ParameterBindingValidationComposite.php @@ -0,0 +1,36 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Composite parameter binding validation service + * @since 1.0.0 + */ +final readonly class ParameterBindingValidationComposite { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param int|string $union Union value. + * @param \Countable&\IteratorAggregate $intersection Intersection value. + * @param (\Countable&\IteratorAggregate)|string $dnf DNF value. + */ + public function __construct ( + public int|string $union, + public \Countable&\IteratorAggregate $intersection, + public (\Countable&\IteratorAggregate)|string $dnf + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/ParameterBindingValidationContract.php b/tests/Stubs/ParameterBindingValidationContract.php new file mode 100644 index 0000000..5cb1fc8 --- /dev/null +++ b/tests/Stubs/ParameterBindingValidationContract.php @@ -0,0 +1,20 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Parameter binding validation contract + * @since 1.0.0 + */ +interface ParameterBindingValidationContract {} \ No newline at end of file diff --git a/tests/Stubs/ParameterBindingValidationService.php b/tests/Stubs/ParameterBindingValidationService.php new file mode 100644 index 0000000..5fc01c5 --- /dev/null +++ b/tests/Stubs/ParameterBindingValidationService.php @@ -0,0 +1,38 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Parameter binding validation service + * @since 1.0.0 + */ +final readonly class ParameterBindingValidationService implements ParameterBindingValidationContract { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param int $port Port number. + * @param null|string $host Host name. + * @param float $timeout Timeout value. + * @param null|ParameterBindingValidationContract $dependency Optional dependency. + */ + public function __construct ( + public int $port = 3306, + public ?string $host = null, + public float $timeout = 1.0, + public ?ParameterBindingValidationContract $dependency = null + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/TaggedCircularConsumer.php b/tests/Stubs/TaggedCircularConsumer.php new file mode 100644 index 0000000..4f1931e --- /dev/null +++ b/tests/Stubs/TaggedCircularConsumer.php @@ -0,0 +1,35 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\Tagged; + +/** + * ### Consumer of tagged services with circular dependencies + * @since 1.0.0 + */ +final readonly class TaggedCircularConsumer { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param list $services Tagged services. + */ + public function __construct ( + #[Tagged('validation.circular')] + public array $services + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/TaggedValidationConsumer.php b/tests/Stubs/TaggedValidationConsumer.php new file mode 100644 index 0000000..58dbda8 --- /dev/null +++ b/tests/Stubs/TaggedValidationConsumer.php @@ -0,0 +1,35 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.2 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +use FireHub\Container\Attribute\Tagged; + +/** + * ### Consumer of tagged validation services + * @since 1.0.0 + */ +final readonly class TaggedValidationConsumer { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param list $services Tagged services. + */ + public function __construct ( + #[Tagged('validation.services')] + public array $services + ) {} + +} \ No newline at end of file diff --git a/tests/Stubs/ValidationConsoleLogger.php b/tests/Stubs/ValidationConsoleLogger.php new file mode 100644 index 0000000..1a1d25f --- /dev/null +++ b/tests/Stubs/ValidationConsoleLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class ValidationConsoleLogger implements ValidationLoggerInterface {} \ No newline at end of file diff --git a/tests/Stubs/ValidationConstructionProbe.php b/tests/Stubs/ValidationConstructionProbe.php new file mode 100644 index 0000000..175d531 --- /dev/null +++ b/tests/Stubs/ValidationConstructionProbe.php @@ -0,0 +1,36 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * ### Tracks constructor execution during validation + * @internal + */ +final class ValidationConstructionProbe { + + /** + * @since 1.0.0 + */ + public static int $constructions = 0; + + /** + * @since 1.0.0 + */ + public function __construct () { + + self::$constructions++; + + } + +} diff --git a/tests/Stubs/ValidationFileLogger.php b/tests/Stubs/ValidationFileLogger.php new file mode 100644 index 0000000..4124cbb --- /dev/null +++ b/tests/Stubs/ValidationFileLogger.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class ValidationFileLogger implements ValidationLoggerInterface {} \ No newline at end of file diff --git a/tests/Stubs/ValidationLoggerInterface.php b/tests/Stubs/ValidationLoggerInterface.php new file mode 100644 index 0000000..afde813 --- /dev/null +++ b/tests/Stubs/ValidationLoggerInterface.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +interface ValidationLoggerInterface {} \ No newline at end of file diff --git a/tests/Stubs/ValidationUnrelatedService.php b/tests/Stubs/ValidationUnrelatedService.php new file mode 100644 index 0000000..41ab399 --- /dev/null +++ b/tests/Stubs/ValidationUnrelatedService.php @@ -0,0 +1,19 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.0 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Stubs; + +/** + * @since 1.0.0 + */ +final class ValidationUnrelatedService {} \ No newline at end of file diff --git a/tests/Unit/AutowiringTest.php b/tests/Unit/AutowiringTest.php index 70d8214..60d74d9 100644 --- a/tests/Unit/AutowiringTest.php +++ b/tests/Unit/AutowiringTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/CollectionInjectionTest.php b/tests/Unit/CollectionInjectionTest.php index a8198fb..689234b 100644 --- a/tests/Unit/CollectionInjectionTest.php +++ b/tests/Unit/CollectionInjectionTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/CollectionServicesTest.php b/tests/Unit/CollectionServicesTest.php index e4c2768..20b3e1e 100644 --- a/tests/Unit/CollectionServicesTest.php +++ b/tests/Unit/CollectionServicesTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/ContainerLifecycleTest.php b/tests/Unit/ContainerLifecycleTest.php index 7ce9572..4723686 100644 --- a/tests/Unit/ContainerLifecycleTest.php +++ b/tests/Unit/ContainerLifecycleTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/ContainerValidationTest.php b/tests/Unit/ContainerValidationTest.php new file mode 100644 index 0000000..4fe7575 --- /dev/null +++ b/tests/Unit/ContainerValidationTest.php @@ -0,0 +1,628 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Validation\ { + Issue, IssueType, Result, Severity +}; +use FireHub\Tests\Container\Stubs\ { + Logger, LoggerInterface, TestFileLogger, TestConsoleLogger, ValidationConsoleLogger, ValidationConstructionProbe, + ValidationFileLogger, ValidationLoggerInterface, ValidationUnrelatedService +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests static container definition validation + * @since 1.0.0 + */ +#[Small] +#[Group('validation')] +#[CoversClass(Container::class)] +final class ContainerValidationTest extends FireHubTestCase { + + /** + * ### Validates an empty container + * @since 1.0.0 + * + * @return void + */ + public function testEmptyContainerIsValid ():void { + + $container = new Container; + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + self::assertFalse($result->hasWarnings()); + self::assertSame([], $result->issues()); + self::assertSame([], $result->errors()); + self::assertSame([], $result->warnings()); + self::assertCount(0, $result); + + } + + /** + * ### Validates a concrete class definition + * @since 1.0.0 + * + * @return void + */ + public function testValidClassDefinition ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + self::assertCount(0, $result); + + } + + /** + * ### Validates an interface implementation + * @since 1.0.0 + * + * @return void + */ + public function testValidInterfaceImplementation ():void { + + $container = new Container; + + $container->singleton( + ValidationLoggerInterface::class, + ValidationFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + self::assertSame([], $result->errors()); + + } + + /** + * ### Detects a missing implementation class + * @since 1.0.0 + * + * @return void + */ + public function testMissingImplementationClass ():void { + + $container = new Container; + + $container->singleton( + LoggerInterface::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingLogger' + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + self::assertCount(1, $result->errors()); + + self::assertSame( + IssueType::MISSING_CLASS, + $result->errors()[0]->type + ); + + self::assertSame( + Severity::ERROR, + $result->errors()[0]->severity + ); + + } + + /** + * ### Detects a non-instantiable implementation + * @since 1.0.0 + * + * @return void + */ + public function testNonInstantiableClass ():void { + + $container = new Container; + + $container->singleton(LoggerInterface::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertSame( + IssueType::MISSING_CLASS, + $result->errors()[0]->type + ); + + } + + /** + * ### Detects an incompatible class implementation + * @since 1.0.0 + * + * @return void + */ + public function testIncompatibleImplementation ():void { + + $container = new Container; + + $container->singleton( + LoggerInterface::class, + Logger::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertSame( + IssueType::TYPE_MISMATCH, + $result->errors()[0]->type + ); + + } + + /** + * ### Validates an existing service instance + * @since 1.0.0 + * + * @return void + */ + public function testValidInstanceDefinition ():void { + + $container = new Container; + + $container->instance( + ValidationLoggerInterface::class, + new ValidationFileLogger() + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Detects an incompatible service instance + * @since 1.0.0 + * + * @return void + */ + public function testIncompatibleInstanceDefinition ():void { + + $container = new Container; + + $container->instance( + LoggerInterface::class, + new Logger + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertSame( + IssueType::TYPE_MISMATCH, + $result->errors()[0]->type + ); + + } + + /** + * ### Validates a direct alias + * @since 1.0.0 + * + * @return void + */ + public function testValidDirectAlias ():void { + + $container = new Container; + + $container->singleton(ValidationFileLogger::class); + + $container->alias( + ValidationLoggerInterface::class, + ValidationFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates a chained alias + * @since 1.0.0 + * + * @return void + */ + public function testValidAliasChain ():void { + + $container = new Container; + + $container->singleton(ValidationFileLogger::class); + + $container->alias( + ValidationLoggerInterface::class, + ValidationFileLogger::class + ); + + $container->alias( + 'FireHub\\Tests\\Container\\Stubs\\LoggerAlias', + ValidationLoggerInterface::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Detects an alias with a missing target + * @since 1.0.0 + * + * @return void + */ + public function testMissingAliasTarget ():void { + + $container = new Container; + + $container->alias( + LoggerInterface::class, + TestFileLogger::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertSame( + IssueType::INVALID_ALIAS, + $result->errors()[0]->type + ); + + } + + /** + * ### Detects a self-referencing alias + * @since 1.0.0 + * + * @return void + */ + public function testSelfReferencingAlias ():void { + + $container = new Container; + + $container->alias( + LoggerInterface::class, + LoggerInterface::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertSame( + IssueType::CIRCULAR_ALIAS, + $result->errors()[0]->type + ); + + } + + /** + * ### Detects a circular alias chain + * @since 1.0.0 + * + * @return void + */ + public function testCircularAliasChain ():void { + + $container = new Container; + + $container->alias( + LoggerInterface::class, + TestFileLogger::class + ); + + $container->alias( + TestFileLogger::class, + LoggerInterface::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertSame( + IssueType::CIRCULAR_ALIAS, + $result->errors()[0]->type + ); + + self::assertSame( + [ + LoggerInterface::class, + TestFileLogger::class, + LoggerInterface::class + ], + $result->errors()[0]->path + ); + + } + + /** + * ### Detects an incompatible alias target + * @since 1.0.0 + * + * @return void + */ + public function testIncompatibleAliasTarget ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $container->alias( + LoggerInterface::class, + Logger::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertSame( + IssueType::TYPE_MISMATCH, + $result->errors()[0]->type + ); + + } + + /** + * ### Reports factory validation limitations + * @since 1.0.0 + * + * @return void + */ + public function testFactoryProducesWarning ():void { + + $container = new Container; + + $container->factory( + LoggerInterface::class, + fn($resolver) => new TestFileLogger + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + self::assertTrue($result->hasWarnings()); + + self::assertCount(1, $result->warnings()); + + self::assertSame( + IssueType::UNVERIFIABLE_FACTORY, + $result->warnings()[0]->type + ); + + self::assertSame( + Severity::WARNING, + $result->warnings()[0]->severity + ); + + } + + /** + * ### Collects multiple independent validation issues + * @since 1.0.0 + * + * @return void + */ + public function testCollectsMultipleIssues ():void { + + $container = new Container; + + $container->singleton( + ValidationLoggerInterface::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingLogger' + ); + + $container->alias( + ValidationFileLogger::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingAliasTarget' + ); + + $container->factory( + ValidationConsoleLogger::class, + fn($resolver) => new TestConsoleLogger + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + self::assertTrue($result->hasWarnings()); + + self::assertCount(2, $result->errors()); + self::assertCount(1, $result->warnings()); + + self::assertSame( + [ + IssueType::MISSING_CLASS, + IssueType::INVALID_ALIAS + ], + array_map( + static fn(Issue $issue):IssueType => $issue->type, + $result->errors() + ) + ); + + self::assertSame( + IssueType::UNVERIFIABLE_FACTORY, + $result->warnings()[0]->type + ); + + + } + + /** + * ### Validation does not freeze the container + * @since 1.0.0 + * + * @return void + */ + public function testValidationDoesNotFreezeContainer ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $container->validate(); + + self::assertFalse($container->isFrozen()); + + $container->singleton(TestFileLogger::class); + + self::assertFalse($container->isFrozen()); + + } + + /** + * ### Validation does not instantiate registered services + * @since 1.0.0 + * + * @return void + */ + public function testValidationDoesNotInstantiateServices ():void { + + $container = new Container; + + $container->singleton( + ValidationConstructionProbe::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + self::assertSame( + 0, + ValidationConstructionProbe::$constructions + ); + + } + + /** + * ### Validation does not execute factory callbacks + * @since 1.0.0 + * + * @return void + */ + public function testValidationDoesNotExecuteFactories ():void { + + $executions = 0; + + $container = new Container; + + $container->factory( + LoggerInterface::class, + function ($resolver) use (&$executions) { + + $executions++; + + return new TestFileLogger; + + } + ); + + $result = $container->validate(); + + self::assertSame(0, $executions); + self::assertTrue($result->isValid()); + self::assertTrue($result->hasWarnings()); + + } + + /** + * ### Validates frozen container definitions + * @since 1.0.0 + * + * @return void + */ + public function testValidatesFrozenContainer ():void { + + $container = new Container; + + $container->singleton(Logger::class); + $container->freeze(); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + self::assertTrue($container->isFrozen()); + + } + + /** + * ### Validation result supports iteration + * @since 1.0.0 + * + * @return void + */ + public function testValidationResultIteration ():void { + + $issue = new Issue( + IssueType::MISSING_CLASS, + Severity::ERROR, + Logger::class, + 'Missing implementation.' + ); + + $result = new Result([$issue]); + + self::assertCount(1, $result); + self::assertSame([$issue], iterator_to_array($result)); + + } + + /** + * ### Warnings do not invalidate validation results + * @since 1.0.0 + * + * @return void + */ + public function testWarningsDoNotInvalidateResult ():void { + + $issue = new Issue( + IssueType::UNVERIFIABLE_FACTORY, + Severity::WARNING, + LoggerInterface::class, + 'Factory cannot be statically verified.' + ); + + $result = new Result([$issue]); + + self::assertTrue($result->isValid()); + self::assertTrue($result->hasWarnings()); + self::assertSame([], $result->errors()); + self::assertCount(1, $result->warnings()); + + } + +} \ No newline at end of file diff --git a/tests/Unit/ContextualBindingTest.php b/tests/Unit/ContextualBindingTest.php index 24a4834..5d06451 100644 --- a/tests/Unit/ContextualBindingTest.php +++ b/tests/Unit/ContextualBindingTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/DependencyGraphValidationTest.php b/tests/Unit/DependencyGraphValidationTest.php new file mode 100644 index 0000000..a02c344 --- /dev/null +++ b/tests/Unit/DependencyGraphValidationTest.php @@ -0,0 +1,2311 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container\Tests + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Container\Container; +use FireHub\Container\Validation\ { + Issue, IssueType, Result, Severity +}; +use FireHub\Tests\Container\Stubs\ { + CircularA, CircularB, CollectionInjectionConflictingAttributesConsumer, CollectionValidationCircular, + CollectionValidationConsumer, CollectionValidationContract, CollectionValidationFirst, + CollectionValidationMissingDependency, CollectionValidationNested, CollectionValidationSecond, ContextualCache, + ContextualCacheService, ContextualCircularA, ContextualCircularB, ContextualDnfConsumer, ContextualFileLogger, + ContextualIntersectionConsumer, ContextualLogger, ContextualNamedConsumer, ContextualUnionConsumer, DnfCacheService, + DnfDefaultConsumer, DnfFileLogger, DnfFirstBranchConsumer, DnfNestedConsumer, DnfNoCandidateConsumer, + DnfRequiredNullableConsumer, DnfSecondBranchConsumer, DnfSerializableCache, DnfTwoIntersectionsConsumer, + DnfUniversalService, IntersectionConsumer, Logger, LoggerInterface, ParameterBindingValidationComposite, + ParameterBindingValidationService, TaggedCircularConsumer, TaggedValidationConsumer, TestFileLogger, TestLogger, + UnionA, UnionAmbiguous, UnionB, UnionNullable, UnionOnlyA, ValidationConstructionProbe +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests static constructor dependency graph validation + * @since 1.0.0 + */ +#[Small] +#[Group('validation')] +#[CoversClass(Container::class)] +final class DependencyGraphValidationTest extends FireHubTestCase { + + /** + * ### Validates an acyclic dependency graph + * + * A constructor dependency graph without unresolved dependencies or circular relationships must produce no + * validation errors. + * @since 1.0.0 + * + * @return void + */ + public function testValidDependencyGraph ():void { + + $container = new Container; + + $container->singleton(Logger::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + self::assertSame([], $result->errors()); + + } + + /** + * ### Detects a circular constructor dependency + * + * Two services depending on each other must produce a circular dependency validation error. + * @since 1.0.0 + * + * @return void + */ + public function testCircularDependency ():void { + + $container = new Container; + + $container->singleton(CircularA::class); + $container->singleton(CircularB::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + $issues = $this->issuesOfType( + $result, + IssueType::CIRCULAR_DEPENDENCY + ); + + self::assertCount(1, $issues); + + self::assertSame( + Severity::ERROR, + $issues[0]->severity + ); + + } + + /** + * ### Deduplicates circular dependency rotations + * + * The same directed dependency cycle must be reported only once even when multiple registered services provide + * different traversal starting points. + * @since 1.0.0 + * + * @return void + */ + public function testCircularDependencyDeduplication ():void { + + $container = new Container; + + $container->singleton(CircularA::class); + $container->singleton(CircularB::class); + + $result = $container->validate(); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::CIRCULAR_DEPENDENCY + ) + ); + + } + + /** + * ### Preserves circular dependency paths + * + * Circular dependency issues must contain the dependency path, including the service identifier that closes the + * cycle. + * @since 1.0.0 + * + * @return void + */ + public function testCircularDependencyPath ():void { + + $container = new Container; + + $container->singleton(CircularA::class); + $container->singleton(CircularB::class); + + $result = $container->validate(); + + $issues = $this->issuesOfType( + $result, + IssueType::CIRCULAR_DEPENDENCY + ); + + self::assertCount(1, $issues); + + $path = $issues[0]->path; + + self::assertGreaterThanOrEqual(3, count($path)); + + self::assertSame( + $path[0], + $path[count($path) - 1] + ); + + self::assertContains(CircularA::class, $path); + self::assertContains(CircularB::class, $path); + + } + + /** + * ### Detects a circular alias chain + * + * Circular service aliases must be reported as alias validation errors rather than constructor dependency cycles. + * @since 1.0.0 + * + * @return void + */ + public function testCircularAliasDoesNotProduceDependencyCycle ():void { + + $container = new Container; + + $container->alias( + ValidationFileLogger::class, + TestFileLogger::class + ); + + $container->alias( + TestFileLogger::class, + ValidationFileLogger::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertNotEmpty( + $this->issuesOfType( + $result, + IssueType::CIRCULAR_ALIAS + ) + ); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::CIRCULAR_DEPENDENCY + ) + ); + + } + + /** + * ### Detects an invalid alias target + * + * An alias referencing an unregistered target must produce an invalid alias issue without reporting a + * constructor dependency cycle. + * @since 1.0.0 + * + * @return void + */ + public function testInvalidAliasDoesNotProduceDependencyCycle ():void { + + $container = new Container; + + $container->alias( + ValidationFileLogger::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingAliasTarget' + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertNotEmpty( + $this->issuesOfType( + $result, + IssueType::INVALID_ALIAS + ) + ); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::CIRCULAR_DEPENDENCY + ) + ); + + } + + /** + * ### Does not instantiate services during graph validation + * + * Constructor dependency graph validation must inspect metadata without creating registered service instances. + * @since 1.0.0 + * + * @return void + */ + public function testDependencyGraphValidationDoesNotInstantiateServices ():void { + + ValidationConstructionProbe::$constructions = 0; + + $container = new Container; + + $container->singleton( + ValidationConstructionProbe::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + 0, + ValidationConstructionProbe::$constructions + ); + + } + + /** + * ### Does not execute factories during graph validation + * + * Registered factory callbacks must never be executed during static dependency graph analysis. + * @since 1.0.0 + * + * @return void + */ + public function testDependencyGraphValidationDoesNotExecuteFactories ():void { + + $executions = 0; + + $container = new Container; + + $container->factory( + LoggerInterface::class, + function ($resolver) use (&$executions) { + + $executions++; + + return new TestFileLogger; + + } + ); + + $result = $container->validate(); + + self::assertSame(0, $executions); + + self::assertTrue($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::UNVERIFIABLE_FACTORY + ) + ); + + } + + /** + * ### Preserves validation results across repeated executions + * + * Repeated validation of an unchanged container must produce equivalent diagnostic results without retaining the + * cycle detection state. + * @since 1.0.0 + * + * @return void + */ + public function testRepeatedValidationProducesEquivalentResults ():void { + + $container = new Container; + + $container->singleton(CircularA::class); + $container->singleton(CircularB::class); + + $first = $container->validate(); + $second = $container->validate(); + + self::assertEquals( + $first->issues(), + $second->issues() + ); + + self::assertCount( + 1, + $this->issuesOfType( + $second, + IssueType::CIRCULAR_DEPENDENCY + ) + ); + + } + + /** + * ### Validates multiple independent services + * + * A circular dependency in one service graph must not prevent validation of other independently registered + * services. + * @since 1.0.0 + * + * @return void + */ + public function testCircularDependencyDoesNotInterruptOtherValidation ():void { + + $container = new Container; + + $container->singleton(CircularA::class); + $container->singleton(CircularB::class); + + $container->singleton( + LoggerInterface::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingLogger' + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::CIRCULAR_DEPENDENCY + ) + ); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::MISSING_CLASS + ) + ); + + } + + /** + * ### Filters validation issues by type + * + * Returns all validation issues matching the specified issue category. + * @since 1.0.0 + * + * @param \FireHub\Container\Validation\Result $result

+ * Container validation result. + *

+ * @param \FireHub\Container\Validation\IssueType $type

+ * Requested validation issue type. + *

+ * + * @return list<\FireHub\Container\Validation\Issue> Matching validation issues. + */ + private function issuesOfType ( + Result $result, + IssueType $type + ):array { + + return array_values( + array_filter( + $result->issues(), + static fn (Issue $issue):bool => $issue->type === $type + ) + ); + + } + + /** + * ### Detects an invalid constructor dependency plan + * + * Invalid injection attribute configurations must be reported as validation issues instead of interrupting the + * validation process. + * @since 1.0.0 + * + * @return void + */ + public function testInvalidDependencyPlan ():void { + + $container = new Container; + + $container->singleton( + CollectionInjectionConflictingAttributesConsumer::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::INVALID_DEPENDENCY_PLAN + ) + ); + + } + + /** + * ### Continues validation after an invalid dependency plan + * + * A constructor dependency plan compilation failure must not prevent independently registered services from + * being validated. + * @since 1.0.0 + * + * @return void + */ + public function testInvalidDependencyPlanDoesNotInterruptValidation ():void { + + $container = new Container; + + $container->singleton( + CollectionInjectionConflictingAttributesConsumer::class + ); + + $container->singleton( + LoggerInterface::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingLogger' + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::INVALID_DEPENDENCY_PLAN + ) + ); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::MISSING_CLASS + ) + ); + + } + + /** + * ### Accepts a contextual factory target + * + * Contextual bindings referencing registered factories must not be rejected solely because their implementation + * types cannot be statically verified. + * + * Factory callbacks must not execute during validation. + * @since 1.0.0 + * + * @return void + */ + public function testContextualFactoryTarget ():void { + + $executions = 0; + + $container = new Container; + + $container->singleton( + ContextualNamedConsumer::class + ); + + $container->factory( + ContextualLogger::class, + function ($resolver) use (&$executions) { + + $executions++; + + return new ContextualFileLogger; + + } + ); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualLogger::class + ); + + $result = $container->validate(); + + self::assertSame(0, $executions); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::UNVERIFIABLE_FACTORY + ) + ); + + } + + /** + * ### Detects a circular contextual dependency + * + * Circular dependencies introduced through contextual bindings must be detected and reported as constructor + * dependency cycles. + * @since 1.0.0 + * + * @return void + */ + public function testCircularContextualDependency ():void { + + $container = new Container; + + $container->singleton( + ContextualCircularA::class + ); + + $container->singleton( + ContextualCircularB::class + ); + + $container->bindContextual( + ContextualCircularA::class, + ContextualCircularB::class, + ContextualCircularB::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::CIRCULAR_DEPENDENCY + ) + ); + + } + + /** + * ### Validates a union dependency with one eligible candidate + * + * A union dependency with a uniquely selectable implementation must pass static dependency graph validation. + * @since 1.0.0 + * + * @return void + */ + public function testUnionDependencyWithSingleCandidate ():void { + + $container = new Container; + + $container->singleton(UnionOnlyA::class); + $container->singleton(UnionA::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::AMBIGUOUS_DEPENDENCY + ) + ); + + } + + /** + * ### Detects ambiguous union dependencies + * + * Multiple eligible union alternatives must produce an ambiguous dependency validation issue. + * @since 1.0.0 + * + * @return void + */ + public function testAmbiguousUnionDependency ():void { + + $container = new Container; + + $container->singleton(UnionAmbiguous::class); + + $container->singleton(UnionA::class); + $container->singleton(UnionB::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::AMBIGUOUS_DEPENDENCY + ) + ); + + } + + /** + * ### Validates nullable union dependencies with default values + * + * A nullable union dependency with an explicit default value must remain valid when no dependency candidate is + * available. + * @since 1.0.0 + * + * @return void + */ + public function testNullableUnionDependencyWithDefault ():void { + + $container = new Container; + + $container->singleton(UnionNullable::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::MISSING_DEPENDENCY + ) + ); + + } + + /** + * ### Validates an intersection dependency with one eligible candidate + * + * An intersection dependency must be considered valid when exactly one registered implementation satisfies all + * declared contracts. + * @since 1.0.0 + * + * @return void + */ + public function testIntersectionDependencyWithSingleCandidate ():void { + + $container = new Container; + + $container->singleton(IntersectionConsumer::class); + $container->singleton(TestFileLogger::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::MISSING_DEPENDENCY + ) + ); + + } + + /** + * ### Detects missing intersection dependencies + * + * An intersection dependency without an implementation satisfying every required contract must produce a missing + * dependency issue. + * @since 1.0.0 + * + * @return void + */ + public function testIntersectionDependencyWithoutCandidate ():void { + + $container = new Container; + + $container->singleton(IntersectionConsumer::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::MISSING_DEPENDENCY + ) + ); + + } + + /** + * ### Validates the first DNF intersection branch + * + * A DNF dependency must pass validation when one registered implementation satisfies the first intersection branch. + * @since 1.0.0 + * + * @return void + */ + public function testDnfFirstBranchValidation ():void { + + $container = new Container; + + $container->singleton(DnfFirstBranchConsumer::class); + $container->singleton(DnfFileLogger::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates the second DNF-named branch + * + * A DNF dependency must pass validation when one registered implementation satisfies its named alternative. + * @since 1.0.0 + * + * @return void + */ + public function testDnfSecondBranchValidation ():void { + + $container = new Container; + + $container->singleton(DnfSecondBranchConsumer::class); + $container->singleton(DnfCacheService::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates a DNF dependency containing two intersection branches + * + * A registered implementation satisfying either complete intersection branch must be accepted by dependency + * graph validation. + * @since 1.0.0 + * + * @return void + */ + public function testDnfTwoIntersectionBranches ():void { + + $container = new Container; + + $container->singleton(DnfTwoIntersectionsConsumer::class); + $container->singleton(DnfSerializableCache::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates an implementation satisfying multiple DNF branches + * + * A single registered implementation satisfying more than one DNF branch must not be considered ambiguous. + * @since 1.0.0 + * + * @return void + */ + public function testDnfSingleImplementationMatchingMultipleBranches ():void { + + $container = new Container; + + $container->singleton(DnfTwoIntersectionsConsumer::class); + $container->singleton(DnfUniversalService::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Detects ambiguous DNF dependencies + * + * Multiple registered implementations satisfying different DNF alternatives must produce an ambiguous dependency + * issue. + * @since 1.0.0 + * + * @return void + */ + public function testAmbiguousDnfDependency ():void { + + $container = new Container; + + $container->singleton(DnfFirstBranchConsumer::class); + + $container->singleton(DnfFileLogger::class); + $container->singleton(DnfCacheService::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::AMBIGUOUS_DEPENDENCY + ) + ); + + } + + /** + * ### Detects missing DNF dependency candidates + * + * A required DNF dependency must produce a missing dependency issue when no registered implementation satisfies + * any complete branch. + * @since 1.0.0 + * + * @return void + */ + public function testMissingDnfDependency ():void { + + $container = new Container; + + $container->singleton(DnfNoCandidateConsumer::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::MISSING_DEPENDENCY + ) + ); + + } + + /** + * ### Accepts a DNF dependency with a null default + * + * A DNF constructor parameter with an explicit null default must remain valid when no eligible implementation is + * registered. + * @since 1.0.0 + * + * @return void + */ + public function testDnfDependencyWithNullDefault ():void { + + $container = new Container; + + $container->singleton(DnfDefaultConsumer::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Rejects a required nullable DNF dependency without candidates + * + * Nullable constructor parameters without explicit default values remain required and must produce a missing + * dependency issue. + * @since 1.0.0 + * + * @return void + */ + public function testRequiredNullableDnfDependency ():void { + + $container = new Container; + + $container->singleton(DnfRequiredNullableConsumer::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::MISSING_DEPENDENCY + ) + ); + + } + + /** + * ### Validates nested DNF constructor dependencies + * + * Dependency graph validation must recursively inspect constructor dependencies selected through DNF types. + * @since 1.0.0 + * + * @return void + */ + public function testNestedDnfDependencyGraph ():void { + + $container = new Container; + + $container->singleton(DnfNestedConsumer::class); + $container->singleton(DnfFileLogger::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::MISSING_DEPENDENCY + ) + ); + + } + + /** + * ### Validates an empty tagged collection + * + * An unknown tag must produce an empty collection without introducing + * missing dependency validation issues. + * @since 1.0.0 + * + * @return void + */ + public function testEmptyTaggedCollectionValidation ():void { + + $container = new Container; + + $container->singleton(TaggedValidationConsumer::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType($result, IssueType::MISSING_DEPENDENCY) + ); + + } + + /** + * ### Validates multiple tagged services + * + * Every tagged service must be included in dependency graph validation. + * @since 1.0.0 + * + * @return void + */ + public function testMultipleTaggedServicesValidation ():void { + + $container = new Container; + + $container->singleton(TaggedValidationConsumer::class); + $container->singleton(Logger::class); + $container->singleton(ValidationConstructionProbe::class); + + $container->tag( + 'validation.services', + Logger::class, + ValidationConstructionProbe::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Detects circular dependencies through tagged injection + * + * Tagged collection traversal must preserve the active dependency path + * so that circular dependencies are detected. + * @since 1.0.0 + * + * @return void + */ + public function testCircularTaggedDependencyValidation ():void { + + $container = new Container; + + $container->singleton(TaggedCircularConsumer::class); + + $container->tag( + 'validation.circular', + TaggedCircularConsumer::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType($result, IssueType::CIRCULAR_DEPENDENCY) + ); + + } + + /** + * ### Validates tagged aliases + * + * Tagged aliases must be resolved to their canonical service definitions during dependency graph validation. + * @since 1.0.0 + * + * @return void + */ + public function testTaggedAliasValidation ():void { + + $container = new Container; + + $container->singleton(TaggedValidationConsumer::class); + $container->singleton(TestFileLogger::class); + + $container->alias( + TestLogger::class, + TestFileLogger::class + ); + + $container->tag( + 'validation.services', + TestLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Does not execute tagged factories during validation + * + * Factory callbacks must never be executed while inspecting tagged constructor dependencies. + * @since 1.0.0 + * + * @return void + */ + public function testTaggedFactoryIsNotExecutedDuringValidation ():void { + + $executed = false; + + $container = new Container; + + $container->singleton(TaggedValidationConsumer::class); + + $container->factory( + LoggerInterface::class, + function ($resolver) use (&$executed):object { + + $executed = true; + + return new Logger; + + } + ); + + $container->tag( + 'validation.services', + LoggerInterface::class + ); + + $container->validate(); + + self::assertFalse($executed); + + } + + /** + * ### Validates an empty collection dependency + * + * Collection injection must remain valid when no matching service implementations are registered. + * @since 1.0.0 + * + * @return void + */ + public function testEmptyCollectionDependencyValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationConsumer::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates a collection with one implementation + * + * A collection containing one compatible registered implementation must pass static validation. + * @since 1.0.0 + * + * @return void + */ + public function testSingleCollectionImplementationValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationConsumer::class); + $container->singleton(CollectionValidationFirst::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates a collection with multiple implementations + * + * All compatible registered implementations must be accepted without producing ambiguity errors. + * @since 1.0.0 + * + * @return void + */ + public function testMultipleCollectionImplementationsValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationConsumer::class); + $container->singleton(CollectionValidationFirst::class); + $container->singleton(CollectionValidationSecond::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType($result, IssueType::AMBIGUOUS_DEPENDENCY) + ); + + } + + /** + * ### Ignores unrelated collection implementations + * + * Services that do not satisfy the requested collection element type must not affect collection validation. + * @since 1.0.0 + * + * @return void + */ + public function testUnrelatedCollectionImplementationValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationConsumer::class); + $container->singleton(Logger::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates collection aliases without duplicate traversal + * + * Multiple identifiers resolving to the same canonical service must not introduce duplicate dependency issues. + * @since 1.0.0 + * + * @return void + */ + public function testCollectionAliasDeduplicationValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationConsumer::class); + $container->singleton(CollectionValidationFirst::class); + + $container->alias( + CollectionValidationContract::class, + CollectionValidationFirst::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Detects circular dependencies through collection injection + * + * A service included in its own injected collection must produce a circular dependency validation issue. + * @since 1.0.0 + * + * @return void + */ + public function testCircularCollectionDependencyValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationCircular::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType($result, IssueType::CIRCULAR_DEPENDENCY) + ); + + } + + /** + * ### Detects missing dependencies inside collection members + * + * Collection validation must recursively inspect selected implementations and report unresolved required + * constructor dependencies. + * @since 1.0.0 + * + * @return void + */ + public function testMissingCollectionMemberDependencyValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationConsumer::class); + $container->singleton(CollectionValidationMissingDependency::class); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertNotEmpty( + $this->issuesOfType($result, IssueType::MISSING_DEPENDENCY) + ); + + } + + /** + * ### Does not execute collection factories during validation + * + * Static collection validation must never invoke registered factory callbacks. + * @since 1.0.0 + * + * @return void + */ + public function testCollectionFactoryIsNotExecutedDuringValidation ():void { + + $executed = false; + + $container = new Container; + + $container->singleton(CollectionValidationConsumer::class); + + $container->factory( + CollectionValidationContract::class, + function ($resolver) use (&$executed):object { + + $executed = true; + + return new CollectionValidationFirst; + + } + ); + + $container->validate(); + + self::assertFalse($executed); + + } + + /** + * ### Validates nested collection dependencies + * + * Collection injection must be analyzed recursively when encountered inside nested constructor dependency graphs. + * @since 1.0.0 + * + * @return void + */ + public function testNestedCollectionDependencyValidation ():void { + + $container = new Container; + + $container->singleton(CollectionValidationNested::class); + $container->singleton(CollectionValidationFirst::class); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + } + + /** + * ### Validates compatible scalar parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testValidScalarParameterBindings ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + [ + 'port' => 3307, + 'host' => 'localhost', + 'timeout' => 2.5 + ] + ); + + self::assertTrue($container->validate()->isValid()); + + } + + /** + * ### Detects incompatible scalar parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testInvalidScalarParameterBinding ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + ['port' => 'invalid'] + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType($result, IssueType::INVALID_PARAMETER_BINDING) + ); + + } + + /** + * ### Accepts integer values for float parameters + * @since 1.0.0 + * + * @return void + */ + public function testIntegerToFloatParameterBinding ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + ['timeout' => 5] + ); + + self::assertTrue($container->validate()->isValid()); + + } + + /** + * ### Accepts null for nullable parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testNullableParameterBinding ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + ['host' => null] + ); + + self::assertTrue($container->validate()->isValid()); + + } + + /** + * ### Rejects null for non-nullable parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testInvalidNullableParameterBinding ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + ['port' => null] + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType($result, IssueType::INVALID_PARAMETER_BINDING) + ); + + } + + /** + * ### Validates compatible object parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testValidObjectParameterBinding ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + ['dependency' => new ParameterBindingValidationService] + ); + + self::assertTrue($container->validate()->isValid()); + + } + + /** + * ### Detects incompatible object parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testInvalidObjectParameterBinding ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + ['dependency' => new \stdClass] + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType($result, IssueType::INVALID_PARAMETER_BINDING) + ); + + } + + /** + * ### Validates compatible union parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testValidUnionParameterBinding ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationComposite::class, + ['union' => 'value'] + ); + + self::assertSame( + [], + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Detects incompatible union parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testInvalidUnionParameterBinding ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationComposite::class, + ['union' => new \stdClass] + ); + + $result = $container->validate(); + + self::assertCount( + 1, + $this->issuesOfType($result, IssueType::INVALID_PARAMETER_BINDING) + ); + + } + + /** + * ### Validates compatible intersection parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testValidIntersectionParameterBinding ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationComposite::class, + ['intersection' => new \ArrayObject] + ); + + self::assertSame( + [], + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Detects incompatible intersection parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testInvalidIntersectionParameterBinding ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationComposite::class, + ['intersection' => new \stdClass] + ); + + self::assertCount( + 1, + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Validates compatible DNF parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testValidDnfParameterBinding ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationComposite::class, + ['dnf' => new \ArrayObject] + ); + + self::assertSame( + [], + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Detects incompatible DNF parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testInvalidDnfParameterBinding ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationComposite::class, + ['dnf' => new \stdClass] + ); + + self::assertCount( + 1, + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Detects bindings for nonexistent constructor parameters + * @since 1.0.0 + * + * @return void + */ + public function testNonexistentParameterBinding ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationService::class, + ['unknown' => 'value'] + ); + + self::assertCount( + 1, + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Validates explicit bindings before constructor defaults + * @since 1.0.0 + * + * @return void + */ + public function testInvalidBindingOverridesDefault ():void { + + $container = new Container; + + $container->singleton(ParameterBindingValidationService::class); + + $container->bindParameters( + ParameterBindingValidationService::class, + ['port' => 'invalid'] + ); + + self::assertCount( + 1, + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Detects parameter bindings for unknown implementation classes + * @since 1.0.0 + * + * @return void + */ + public function testUnknownParameterBindingClass ():void { + + $container = new Container; + + $container->bindParameters( + 'FireHub\\Tests\\Container\\Stubs\\NonexistentBindingClass', + ['value' => 1] + ); + + self::assertCount( + 1, + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Collects multiple invalid parameter bindings + * @since 1.0.0 + * + * @return void + */ + public function testMultipleInvalidParameterBindings ():void { + + $container = new Container; + + $container->bindParameters( + ParameterBindingValidationService::class, + [ + 'port' => 'invalid', + 'host' => new \stdClass, + 'timeout' => false + ] + ); + + self::assertCount( + 3, + $this->issuesOfType( + $container->validate(), + IssueType::INVALID_PARAMETER_BINDING + ) + ); + + } + + /** + * ### Validates a named contextual dependency binding + * + * A compatible contextual implementation must satisfy the consumer's declared dependency contract. + * @since 1.0.0 + * + * @return void + */ + public function testValidNamedContextualBinding ():void { + + $container = new Container; + + $container->singleton(ContextualNamedConsumer::class); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::UNUSED_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Validates contextual bindings without consumer registration + * + * Contextual binding configuration must be validated independently of explicit consumer service registration. + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingWithoutRegisteredConsumer ():void { + + $container = new Container; + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::UNUSED_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Validates contextual bindings for union dependencies + * + * A contextual dependency contract matching one union member must be recognized as used. + * @since 1.0.0 + * + * @return void + */ + public function testValidUnionContextualBinding ():void { + + $container = new Container; + + $container->singleton(ContextualUnionConsumer::class); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::UNUSED_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Validates contextual bindings for intersection dependencies + * + * A contextual implementation satisfying every intersection member must pass static validation. + * @since 1.0.0 + * + * @return void + */ + public function testValidIntersectionContextualBinding ():void { + + $container = new Container; + + $container->singleton(ContextualIntersectionConsumer::class); + + $container->bindContextual( + ContextualIntersectionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::UNUSED_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Validates contextual bindings for DNF dependencies + * + * Contextual dependency contracts referenced within nested DNF branches must be recognized during validation. + * @since 1.0.0 + * + * @return void + */ + public function testValidDnfContextualBinding ():void { + + $container = new Container; + + $container->singleton(ContextualDnfConsumer::class); + + $container->bindContextual( + ContextualDnfConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::UNUSED_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Detects contextual bindings referencing unknown consumers + * + * A contextual binding whose consumer class does not exist must produce an invalid contextual binding issue. + * @since 1.0.0 + * + * @return void + */ + public function testUnknownContextualConsumer ():void { + + $container = new Container; + + $container->bindContextual( + 'FireHub\\Tests\\Container\\Stubs\\MissingContextualConsumer', + ContextualLogger::class, + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Detects contextual bindings referencing unknown dependency contracts + * + * Contextual dependency contracts must reference existing classes or interfaces. + * @since 1.0.0 + * + * @return void + */ + public function testUnknownContextualDependency ():void { + + $container = new Container; + + $container->bindContextual( + ContextualNamedConsumer::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingContextualContract', + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Detects contextual bindings referencing unknown implementations + * + * A contextual implementation must reference a registered service or an implicitly constructible class. + * @since 1.0.0 + * + * @return void + */ + public function testUnknownContextualImplementation ():void { + + $container = new Container; + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + 'FireHub\\Tests\\Container\\Stubs\\MissingContextualImplementation' + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Detects incompatible contextual implementations + * + * A contextual implementation that does not satisfy its registered dependency contract must be rejected. + * @since 1.0.0 + * + * @return void + */ + public function testIncompatibleContextualImplementation ():void { + + $container = new Container; + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + \stdClass::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Reports contextual bindings unused by constructor injection + * + * A structurally valid contextual binding must produce a warning when its dependency contract is not referenced + * by any eligible constructor parameter. + * @since 1.0.0 + * + * @return void + */ + public function testUnusedContextualBinding ():void { + + $container = new Container; + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualCache::class, + ContextualCacheService::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + $issues = $this->issuesOfType( + $result, + IssueType::UNUSED_CONTEXTUAL_BINDING + ); + + self::assertCount(1, $issues); + + self::assertSame( + Severity::WARNING, + $issues[0]->severity + ); + + } + + /** + * ### Reports contextual bindings shadowed by explicit parameter bindings + * + * Explicit constructor parameter bindings take precedence over contextual dependency resolution. + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingShadowedByParameterBinding ():void { + + $container = new Container; + + $container->singleton(ContextualNamedConsumer::class); + + $container->bindParameters( + ContextualNamedConsumer::class, + [ + 'logger' => new ContextualFileLogger + ] + ); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::UNUSED_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Detects ambiguous contextual dependency candidates + * + * Two contextual bindings selecting distinct implementations for the same union parameter must produce an + * ambiguous dependency issue. + * @since 1.0.0 + * + * @return void + */ + public function testAmbiguousContextualBindings ():void { + + $container = new Container; + + $container->singleton(ContextualUnionConsumer::class); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualUnionConsumer::class, + ContextualCache::class, + ContextualCacheService::class + ); + + $result = $container->validate(); + + self::assertFalse($result->isValid()); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::AMBIGUOUS_DEPENDENCY + ) + ); + + } + + /** + * ### Validates contextual implementations referenced through service aliases + * + * A contextual implementation referenced through a valid alias must retain its underlying type compatibility. + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingWithAliasImplementation ():void { + + $container = new Container; + + $container->singleton(ContextualFileLogger::class); + + $container->alias( + ContextualLogger::class, + ContextualFileLogger::class + ); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualLogger::class + ); + + $result = $container->validate(); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + } + + /** + * ### Does not execute contextual factory implementations during validation + * + * Static contextual binding validation must not execute registered factory callbacks. + * @since 1.0.0 + * + * @return void + */ + public function testContextualBindingValidationDoesNotExecuteFactories ():void { + + $executions = 0; + + $container = new Container; + + $container->singleton(ContextualNamedConsumer::class); + + $container->factory( + ContextualLogger::class, + function ($resolver) use (&$executions) { + + $executions++; + + return new ContextualFileLogger; + + } + ); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualLogger::class + ); + + $result = $container->validate(); + + self::assertSame(0, $executions); + + self::assertTrue($result->isValid()); + + self::assertSame( + [], + $this->issuesOfType( + $result, + IssueType::INVALID_CONTEXTUAL_BINDING + ) + ); + + self::assertCount( + 1, + $this->issuesOfType( + $result, + IssueType::UNVERIFIABLE_FACTORY + ) + ); + + } + +} \ No newline at end of file diff --git a/tests/Unit/DnfResolutionTest.php b/tests/Unit/DnfResolutionTest.php index eb74cdb..d295b54 100644 --- a/tests/Unit/DnfResolutionTest.php +++ b/tests/Unit/DnfResolutionTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/LifetimeRuntimeTest.php b/tests/Unit/LifetimeRuntimeTest.php new file mode 100644 index 0000000..86f5f62 --- /dev/null +++ b/tests/Unit/LifetimeRuntimeTest.php @@ -0,0 +1,145 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Container\Container; +use FireHub\Container\Exception\ScopeRequiredException; +use FireHub\Testing\FireHubTestCase; +use FireHub\Tests\Container\Stubs\ { + LifetimeValidationNestedSingleton, LifetimeValidationScoped, + LifetimeValidationSingleton, LifetimeValidationTransient +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests runtime service lifetime protection + * @since 1.0.0 + */ +#[Small] +#[Group('lifetime')] +#[CoversClass(Container::class)] +final class LifetimeRuntimeTest extends FireHubTestCase { + + /** + * ### Rejects direct singleton-to-scoped dependencies + * @since 1.0.0 + * + * @return void + */ + public function testDirectSingletonScopedDependency ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + $container->singleton(LifetimeValidationSingleton::class); + + $scope = $container->createScope(); + + try { + + $this->expectException(ScopeRequiredException::class); + + $scope->get(LifetimeValidationSingleton::class); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Rejects transitive singleton-to-scoped dependencies + * @since 1.0.0 + * + * @return void + */ + public function testTransitiveSingletonScopedDependency ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + $container->transient(LifetimeValidationTransient::class); + $container->singleton(LifetimeValidationNestedSingleton::class); + + $scope = $container->createScope(); + + try { + + $this->expectException(ScopeRequiredException::class); + + $scope->get(LifetimeValidationNestedSingleton::class); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Allows scoped dependencies inside transient services + * @since 1.0.0 + * + * @return void + */ + public function testTransientScopedDependency ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + $container->transient(LifetimeValidationTransient::class); + + $scope = $container->createScope(); + + try { + + $first = $scope->get(LifetimeValidationTransient::class); + $second = $scope->get(LifetimeValidationTransient::class); + + self::assertNotSame($first, $second); + self::assertSame($first->dependency, $second->dependency); + + } finally { + + $scope->close(); + + } + + } + + /** + * ### Rejects singleton-to-scoped dependencies from root resolution + * @since 1.0.0 + * + * @return void + */ + public function testRootSingletonScopedDependency ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + $container->singleton(LifetimeValidationSingleton::class); + + $this->expectException(ScopeRequiredException::class); + + $container->get(LifetimeValidationSingleton::class); + + } + +} \ No newline at end of file diff --git a/tests/Unit/LifetimeValidationTest.php b/tests/Unit/LifetimeValidationTest.php new file mode 100644 index 0000000..47c9d6b --- /dev/null +++ b/tests/Unit/LifetimeValidationTest.php @@ -0,0 +1,422 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Container + */ + +namespace FireHub\Tests\Container\Unit; + +use FireHub\Container\Container; +use FireHub\Container\Validation\ { + Issue, IssueType, Result, Severity +}; +use FireHub\Testing\FireHubTestCase; +use FireHub\Tests\Container\Stubs\ { + CollectionValidationConsumer, CollectionValidationFirst, ContextualDnfConsumer, ContextualFileLogger, + ContextualIntersectionConsumer, ContextualLogger, ContextualNamedConsumer, ContextualUnionConsumer, + LifetimeValidationExplicitConsumer, LifetimeValidationFactoryConsumer, LifetimeValidationNestedSingleton, + LifetimeValidationSafeConsumer, LifetimeValidationSafeSingleton, LifetimeValidationScoped, + LifetimeValidationSingleton, LifetimeValidationTransient, TaggedFileLogger, TaggedValidationConsumer +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small +}; + +/** + * ### Tests service lifetime validation + * @since 1.0.0 + */ +#[Small] +#[Group('validation')] +#[CoversClass(Container::class)] +final class LifetimeValidationTest extends FireHubTestCase { + + /** + * ### Gets validation issues of a specified type + * @since 1.0.0 + * + * @return list + */ + private function issuesOfType (Result $result, IssueType $type):array { + + return array_values(array_filter( + $result->issues(), + static fn (Issue $issue):bool => $issue->type === $type + )); + + } + + /** + * ### Detects direct singleton-to-scoped dependencies + * @since 1.0.0 + * + * @return void + */ + public function testDirectSingletonScopedDependency ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + $container->singleton(LifetimeValidationSingleton::class); + + $result = $container->validate(); + + $issues = $this->issuesOfType( + $result, + IssueType::LIFETIME_MISMATCH + ); + + self::assertFalse($result->isValid()); + self::assertCount(1, $issues); + self::assertSame(Severity::ERROR, $issues[0]->severity); + self::assertSame( + [ + LifetimeValidationSingleton::class, + LifetimeValidationScoped::class + ], + $issues[0]->path + ); + + } + + /** + * ### Detects transitive singleton-to-scoped dependencies + * @since 1.0.0 + * + * @return void + */ + public function testTransitiveSingletonScopedDependency ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + $container->transient(LifetimeValidationTransient::class); + $container->singleton(LifetimeValidationNestedSingleton::class); + + $issues = $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ); + + self::assertCount(1, $issues); + + self::assertSame( + [ + LifetimeValidationNestedSingleton::class, + LifetimeValidationTransient::class, + LifetimeValidationScoped::class + ], + $issues[0]->path + ); + + } + + /** + * ### Accepts singleton-to-singleton dependencies + * @since 1.0.0 + * + * @return void + */ + public function testSingletonDependencyIsValid ():void { + + $container = new Container; + + $container->singleton(LifetimeValidationSafeSingleton::class); + $container->singleton(LifetimeValidationSafeConsumer::class); + + self::assertSame( + [], + $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ) + ); + + } + + /** + * ### Accepts transient-to-scoped dependencies + * @since 1.0.0 + * + * @return void + */ + public function testTransientScopedDependencyIsValid ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + $container->transient(LifetimeValidationTransient::class); + + self::assertSame( + [], + $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ) + ); + + } + + /** + * ### Ignores explicitly supplied constructor dependencies + * @since 1.0.0 + * + * @return void + */ + public function testExplicitParameterBindingDoesNotCaptureScopedService ():void { + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + + $container->singleton(LifetimeValidationExplicitConsumer::class); + + $container->bindParameters( + LifetimeValidationExplicitConsumer::class, + [ + 'dependency' => new LifetimeValidationScoped + ] + ); + + self::assertSame( + [], + $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ) + ); + + } + + /** + * ### Does not execute factories during lifetime validation + * @since 1.0.0 + * + * @return void + */ + public function testFactoryIsNotExecuted ():void { + + $executions = 0; + + $container = new Container; + + $container->scoped(LifetimeValidationScoped::class); + + $container->factory( + LifetimeValidationFactoryConsumer::class, + static function () use (&$executions):LifetimeValidationFactoryConsumer { + + $executions++; + + return new LifetimeValidationFactoryConsumer( + new LifetimeValidationScoped + ); + + } + ); + + $container->validate(); + + self::assertSame(0, $executions); + + } + + /** + * ### Detects scoped dependencies selected through contextual bindings + * @since 1.0.0 + * + * @return void + */ + public function testContextualScopedDependency ():void { + + $container = new Container; + + $container->scoped(ContextualFileLogger::class); + $container->singleton(ContextualNamedConsumer::class); + + $container->bindContextual( + ContextualNamedConsumer::class, + ContextualLogger::class, + ContextualFileLogger::class + ); + + $issues = $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ); + + self::assertCount(1, $issues); + + self::assertSame( + [ + ContextualNamedConsumer::class, + ContextualFileLogger::class + ], + $issues[0]->path + ); + + } + + /** + * ### Detects scoped dependencies selected through union types + * @since 1.0.0 + * + * @return void + */ + public function testUnionScopedDependency ():void { + + $container = new Container; + + $container->scoped(ContextualFileLogger::class); + $container->singleton(ContextualUnionConsumer::class); + + $issues = $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ); + + self::assertCount(1, $issues); + + self::assertSame( + [ + ContextualUnionConsumer::class, + ContextualFileLogger::class + ], + $issues[0]->path + ); + + } + + /** + * ### Detects scoped dependencies selected through intersection types + * @since 1.0.0 + * + * @return void + */ + public function testIntersectionScopedDependency ():void { + + $container = new Container; + + $container->scoped(ContextualFileLogger::class); + $container->singleton(ContextualIntersectionConsumer::class); + + $issues = $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ); + + self::assertCount(1, $issues); + + self::assertSame( + [ + ContextualIntersectionConsumer::class, + ContextualFileLogger::class + ], + $issues[0]->path + ); + + } + + /** + * ### Detects scoped dependencies selected through DNF types + * @since 1.0.0 + * + * @return void + */ + public function testDnfScopedDependency ():void { + + $container = new Container; + + $container->scoped(ContextualFileLogger::class); + $container->singleton(ContextualDnfConsumer::class); + + $issues = $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ); + + self::assertCount(1, $issues); + + self::assertSame( + [ + ContextualDnfConsumer::class, + ContextualFileLogger::class + ], + $issues[0]->path + ); + + } + + /** + * ### Detects scoped dependencies injected through tags + * @since 1.0.0 + * + * @return void + */ + public function testTaggedScopedDependency ():void { + + $container = new Container; + + $container->scoped(TaggedFileLogger::class); + $container->singleton(TaggedValidationConsumer::class); + + $container->tag( + 'validation.services', + TaggedFileLogger::class + ); + + $issues = $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ); + + self::assertCount(1, $issues); + + self::assertSame( + [ + TaggedValidationConsumer::class, + TaggedFileLogger::class + ], + $issues[0]->path + ); + + } + + /** + * ### Detects scoped dependencies injected through collections + * @since 1.0.0 + * + * @return void + */ + public function testCollectionScopedDependency ():void { + + $container = new Container; + + $container->scoped(CollectionValidationFirst::class); + $container->singleton(CollectionValidationConsumer::class); + + $issues = $this->issuesOfType( + $container->validate(), + IssueType::LIFETIME_MISMATCH + ); + + self::assertCount(1, $issues); + + self::assertSame( + [ + CollectionValidationConsumer::class, + CollectionValidationFirst::class + ], + $issues[0]->path + ); + + } + +} \ No newline at end of file diff --git a/tests/Unit/MethodInjectionTest.php b/tests/Unit/MethodInjectionTest.php index f79f0aa..9be5c39 100644 --- a/tests/Unit/MethodInjectionTest.php +++ b/tests/Unit/MethodInjectionTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/ParameterBindingTest.php b/tests/Unit/ParameterBindingTest.php index 1b3833b..4e60c9e 100644 --- a/tests/Unit/ParameterBindingTest.php +++ b/tests/Unit/ParameterBindingTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/TaggedInjectionTest.php b/tests/Unit/TaggedInjectionTest.php index c08a879..078233c 100644 --- a/tests/Unit/TaggedInjectionTest.php +++ b/tests/Unit/TaggedInjectionTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */ diff --git a/tests/Unit/TaggedServicesTest.php b/tests/Unit/TaggedServicesTest.php index bd23a80..59f9557 100644 --- a/tests/Unit/TaggedServicesTest.php +++ b/tests/Unit/TaggedServicesTest.php @@ -7,7 +7,7 @@ * @copyright 2026-present The FireHub Project - All rights reserved * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 * - * @php-version >=8.5 + * @php-version >=7.4 * @package Container\Tests */