From 1501da895c357dbe3d83bf12649b2bcf608fd900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 15:17:05 +0200 Subject: [PATCH 01/15] Add container validation classes and validation logic --- src/Container.php | 20 + src/Validation/Issue.php | 83 +++ src/Validation/IssueType.php | 143 +++++ src/Validation/Result.php | 173 ++++++ src/Validation/Severity.php | 41 ++ src/Validator.php | 356 +++++++++++ tests/Stubs/ValidationConsoleLogger.php | 19 + tests/Stubs/ValidationConstructionProbe.php | 36 ++ tests/Stubs/ValidationFileLogger.php | 19 + tests/Stubs/ValidationLoggerInterface.php | 19 + tests/Stubs/ValidationUnrelatedService.php | 19 + tests/Unit/AutowiringTest.php | 2 +- tests/Unit/CollectionInjectionTest.php | 2 +- tests/Unit/CollectionServicesTest.php | 2 +- tests/Unit/ContainerLifecycleTest.php | 2 +- tests/Unit/ContainerValidationTest.php | 628 ++++++++++++++++++++ tests/Unit/ContextualBindingTest.php | 2 +- tests/Unit/DnfResolutionTest.php | 2 +- tests/Unit/MethodInjectionTest.php | 2 +- tests/Unit/ParameterBindingTest.php | 2 +- tests/Unit/TaggedInjectionTest.php | 2 +- tests/Unit/TaggedServicesTest.php | 2 +- 22 files changed, 1566 insertions(+), 10 deletions(-) create mode 100644 src/Validation/Issue.php create mode 100644 src/Validation/IssueType.php create mode 100644 src/Validation/Result.php create mode 100644 src/Validation/Severity.php create mode 100644 src/Validator.php create mode 100644 tests/Stubs/ValidationConsoleLogger.php create mode 100644 tests/Stubs/ValidationConstructionProbe.php create mode 100644 tests/Stubs/ValidationFileLogger.php create mode 100644 tests/Stubs/ValidationLoggerInterface.php create mode 100644 tests/Stubs/ValidationUnrelatedService.php create mode 100644 tests/Unit/ContainerValidationTest.php diff --git a/src/Container.php b/src/Container.php index ba31a08..dd4ecc2 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,23 @@ 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. + * + * @return \FireHub\Container\Validation\Result Container validation result. + */ + public function validate ():Result { + + return new Validator($this->registry)->validate(); + + } + } \ 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..286b642 --- /dev/null +++ b/src/Validation/IssueType.php @@ -0,0 +1,143 @@ + + * @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; + +} \ 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..7eb7c77 --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,356 @@ + + * @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; + +use FireHub\Container\Validation\ { + Issue, IssueType, Result, Severity +}; +use FireHub\Container\Definition\ { + Alias, Cls, Factory, Instance +}; +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. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry + ) {} + + /** + * ### 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 + * + * @return \FireHub\Container\Validation\Result Validation result. + */ + public function validate ():Result { + + $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.' + ); + + } + + } + + 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. + * + * @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: ' . 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/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/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/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 */ From aec0db851612914766058a2521311cefd714d1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 15:31:37 +0200 Subject: [PATCH 02/15] Add DependencyGraph for validating constructor dependency graphs --- src/Container.php | 5 +- src/Validation/DependencyGraph.php | 263 +++++++++++++++++++++++++++++ src/Validator.php | 28 ++- 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 src/Validation/DependencyGraph.php diff --git a/src/Container.php b/src/Container.php index dd4ecc2..aa6e951 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1229,7 +1229,10 @@ public function isFrozen ():bool { */ public function validate ():Result { - return new Validator($this->registry)->validate(); + return new Validator( + $this->registry, + $this->parameters + )->validate(); } diff --git a/src/Validation/DependencyGraph.php b/src/Validation/DependencyGraph.php new file mode 100644 index 0000000..35cdece --- /dev/null +++ b/src/Validation/DependencyGraph.php @@ -0,0 +1,263 @@ + + * @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\ { + ConstructorMetadataCache, DependencyPlanCache, ParameterBindingRegistry, + ParameterPlanCompiler, TypeMatcher +}; +use FireHub\Runtime; + +/** + * ### 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; + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Registry $registry

+ * Registered service definitions. + *

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

+ * Explicit constructor parameter bindings. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry, + private ParameterBindingRegistry $bindings + ) { + + $metadata = new ConstructorMetadataCache; + + $this->matcher = new TypeMatcher($metadata); + + $this->plans = new DependencyPlanCache( + $metadata, + new ParameterPlanCompiler($this->matcher) + ); + + } + + /** + * ### 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 = []; + + foreach ($this->registry->all() as $id => $definition) { + + if (!$definition instanceof Cls) + continue; + + if (!$this->matcher->isConstructible($definition->class)) + continue; + + $this->inspect( + $id, + [], + $issues + ); + + } + + 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\Runtime\Arr\Inspection::inArray() To check if a service identifier is already in the path. + * @uses \FireHub\Container\Registry::find() To retrieve a service definition. + * @uses \FireHub\Container\Validation\DependencyGraph::inspect() To recursively validate child 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\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. + *

+ * + * @return void + */ + private function inspect (string $id, array $path, array &$issues):void { + + if (Runtime\Arr\Inspection::inArray($path, $id)) { + + $path[] = $id; + + $issues[] = new Issue( + IssueType::CIRCULAR_DEPENDENCY, + Severity::ERROR, + $id, + 'Circular dependency detected: ' . Runtime\Str\SB\Delimiter::implode($path, ' -> '), + $path + ); + + return; + + } + + $path[] = $id; + + $definition = $this->registry->find($id); + + if ($definition instanceof Alias) { + + $this->inspect( + $definition->target, + $path, + $issues + ); + + return; + + } + + if ($definition instanceof Instance || $definition instanceof Factory) + return; + + $class = $definition instanceof Cls + ? $definition->class + : $id; + + if (!$this->matcher->isConstructible($class)) + return; + + $plan = $this->plans->get($class); + + foreach ($plan->parameters as $parameter) { + + $name = $parameter->parameter->getName(); + + if ($this->bindings->has($class, $name)) + continue; + + if ($parameter->tag !== null || $parameter->collection !== null) + continue; + + if ($parameter->composite !== null) + continue; + + $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 + ); + + } + + } + +} \ No newline at end of file diff --git a/src/Validator.php b/src/Validator.php index 7eb7c77..6f98e2e 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -14,11 +14,12 @@ namespace FireHub\Container; use FireHub\Container\Validation\ { - Issue, IssueType, Result, Severity + DependencyGraph, Issue, IssueType, Result, Severity }; use FireHub\Container\Definition\ { Alias, Cls, Factory, Instance }; +use FireHub\Container\Internal\ParameterBindingRegistry; use FireHub\Runtime; use ReflectionClass, ReflectionException; @@ -48,11 +49,15 @@ * @param \FireHub\Container\Registry $registry

* Registry containing service definitions. *

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

+ * Explicit constructor parameter bindings. + *

* * @return void */ public function __construct ( - private Registry $registry + private Registry $registry, + private ParameterBindingRegistry $bindings ) {} /** @@ -63,6 +68,13 @@ public function __construct ( * 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\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. + * * @return \FireHub\Container\Validation\Result Validation result. */ public function validate ():Result { @@ -96,6 +108,14 @@ public function validate ():Result { } + $dependency_issues = new DependencyGraph( + $this->registry, + $this->bindings + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$dependency_issues); + + /** @var list<\FireHub\Container\Validation\Issue> $issues */ return new Result($issues); } @@ -216,6 +236,8 @@ private function validateInstance (string $id, Instance $definition, array &$iss * @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. @@ -243,7 +265,7 @@ private function validateAlias (string $id, array &$issues):void { IssueType::CIRCULAR_ALIAS, Severity::ERROR, $id, - 'Circular alias chain detected: ' . implode(' -> ', $path), + 'Circular alias chain detected: ' . Runtime\Str\SB\Delimiter::implode($path, ' -> '), $path ); From 67ed1920e56836e9b82e140293d3c0de090c32bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Thu, 22 Oct 2026 15:47:08 +0200 Subject: [PATCH 03/15] Add contextual dependency support to DependencyGraph and Validator --- src/Container.php | 3 +- src/Validation/DependencyGraph.php | 270 ++++++++++++++++++++++++++++- src/Validator.php | 13 +- 3 files changed, 279 insertions(+), 7 deletions(-) diff --git a/src/Container.php b/src/Container.php index aa6e951..f7eaf0e 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1231,7 +1231,8 @@ public function validate ():Result { return new Validator( $this->registry, - $this->parameters + $this->parameters, + $this->contextual )->validate(); } diff --git a/src/Validation/DependencyGraph.php b/src/Validation/DependencyGraph.php index 35cdece..2f3bc28 100644 --- a/src/Validation/DependencyGraph.php +++ b/src/Validation/DependencyGraph.php @@ -18,9 +18,10 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ConstructorMetadataCache, DependencyPlanCache, ParameterBindingRegistry, - ParameterPlanCompiler, TypeMatcher + ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlanCache, + ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TypeMatcher }; +use FireHub\Container\Exception\AmbiguousDependencyException; use FireHub\Runtime; /** @@ -54,6 +55,14 @@ */ 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; + /** * ### Constructor * @since 1.0.0 @@ -64,12 +73,16 @@ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

* Explicit constructor parameter bindings. *

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

+ * Contextual dependency binding registry. + *

* * @return void */ public function __construct ( private Registry $registry, - private ParameterBindingRegistry $bindings + private ParameterBindingRegistry $bindings, + ContextualBindingRegistry $contextual ) { $metadata = new ConstructorMetadataCache; @@ -81,6 +94,10 @@ public function __construct ( new ParameterPlanCompiler($this->matcher) ); + $this->contextual_selector = new ContextualCandidateSelector( + $contextual + ); + } /** @@ -133,6 +150,8 @@ public function validate ():array { * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if a service identifier is already in the path. * @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. @@ -206,6 +225,30 @@ private function inspect (string $id, array $path, array &$issues):void { if ($parameter->tag !== null || $parameter->collection !== null) continue; + [$ambiguous, $contextual] = $this->selectContextualDependency( + $class, + $parameter, + $path, + $issues + ); + + if ($ambiguous) + continue; + + if ($contextual !== null) { + + $this->inspectContextualDependency( + $class, + $contextual, + $parameter, + $path, + $issues + ); + + continue; + + } + if ($parameter->composite !== null) continue; @@ -260,4 +303,225 @@ private function inspect (string $id, array $path, array &$issues):void { } + /** + * ### 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. + * + * @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. + *

+ * + * @return void + */ + private function inspectContextualDependency (string $consumer, string $dependency, ParameterPlan $parameter, array $path, array &$issues):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) { + + $metadata = new ConstructorMetadataCache; + + $matcher = new TypeMatcher($metadata); + + $selector = new \FireHub\Container\Internal\CandidateSelector( + $matcher, + fn (string $id):bool => $this->registry->has($id), + fn ():iterable => $this->registry->implementations() + ); + + if (!$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 + ); + + } + + /** + * ### 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; + + } + + } + } \ No newline at end of file diff --git a/src/Validator.php b/src/Validator.php index 6f98e2e..1c21b0c 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -19,7 +19,9 @@ use FireHub\Container\Definition\ { Alias, Cls, Factory, Instance }; -use FireHub\Container\Internal\ParameterBindingRegistry; +use FireHub\Container\Internal\ { + ContextualBindingRegistry, ParameterBindingRegistry +}; use FireHub\Runtime; use ReflectionClass, ReflectionException; @@ -52,12 +54,16 @@ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings

* Explicit constructor parameter bindings. *

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

+ * Contextual dependency binding registry. + *

* * @return void */ public function __construct ( private Registry $registry, - private ParameterBindingRegistry $bindings + private ParameterBindingRegistry $bindings, + private ContextualBindingRegistry $contextual ) {} /** @@ -110,7 +116,8 @@ public function validate ():Result { $dependency_issues = new DependencyGraph( $this->registry, - $this->bindings + $this->bindings, + $this->contextual )->validate(); Runtime\Arr\Mutation::push($issues, ...$dependency_issues); From f207bcb31e113ed6696c784bf9952bcc583e1635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 09:47:26 +0200 Subject: [PATCH 04/15] Enhance DependencyGraph with improved cycle reporting and validation handling --- src/Validation/DependencyGraph.php | 292 +++++++-- src/Validation/IssueType.php | 8 + tests/Unit/DependencyGraphValidationTest.php | 595 +++++++++++++++++++ 3 files changed, 837 insertions(+), 58 deletions(-) create mode 100644 tests/Unit/DependencyGraphValidationTest.php diff --git a/src/Validation/DependencyGraph.php b/src/Validation/DependencyGraph.php index 2f3bc28..d155618 100644 --- a/src/Validation/DependencyGraph.php +++ b/src/Validation/DependencyGraph.php @@ -18,10 +18,12 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlanCache, - ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TypeMatcher + CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlan, + DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TypeMatcher +}; +use FireHub\Container\Exception\ { + AmbiguousDependencyException, ServiceInstantiationException }; -use FireHub\Container\Exception\AmbiguousDependencyException; use FireHub\Runtime; /** @@ -63,10 +65,21 @@ */ 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. *

@@ -98,6 +111,12 @@ public function __construct ( $contextual ); + $this->selector = new CandidateSelector( + $this->matcher, + fn (string $id):bool => $this->registry->has($id), + fn ():iterable => $this->registry->implementations() + ); + } /** @@ -117,8 +136,7 @@ public function __construct ( */ public function validate ():array { - $issues = []; - + $issues = []; $reported_cycles = []; foreach ($this->registry->all() as $id => $definition) { if (!$definition instanceof Cls) @@ -130,7 +148,8 @@ public function validate ():array { $this->inspect( $id, [], - $issues + $issues, + $reported_cycles ); } @@ -166,21 +185,27 @@ public function validate ():array { * @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 inspect (string $id, array $path, array &$issues):void { + private function inspect (string $id, array $path, array &$issues, array &$reported_cycles):void { - if (Runtime\Arr\Inspection::inArray($path, $id)) { + $canonical = $this->canonicalId($id); - $path[] = $id; + if ($canonical === null) return; - $issues[] = new Issue( - IssueType::CIRCULAR_DEPENDENCY, - Severity::ERROR, + $id = $canonical; + + if (Runtime\Arr\Inspection::inArray($path, $id)) { + + $this->reportCycle( $id, - 'Circular dependency detected: ' . Runtime\Str\SB\Delimiter::implode($path, ' -> '), - $path + $path, + $issues, + $reported_cycles ); return; @@ -191,18 +216,6 @@ private function inspect (string $id, array $path, array &$issues):void { $definition = $this->registry->find($id); - if ($definition instanceof Alias) { - - $this->inspect( - $definition->target, - $path, - $issues - ); - - return; - - } - if ($definition instanceof Instance || $definition instanceof Factory) return; @@ -213,7 +226,13 @@ private function inspect (string $id, array $path, array &$issues):void { if (!$this->matcher->isConstructible($class)) return; - $plan = $this->plans->get($class); + $plan = $this->safeDependencyPlan( + $class, + $path, + $issues + ); + + if ($plan === null) return; foreach ($plan->parameters as $parameter) { @@ -232,8 +251,7 @@ private function inspect (string $id, array $path, array &$issues):void { $issues ); - if ($ambiguous) - continue; + if ($ambiguous) continue; if ($contextual !== null) { @@ -242,7 +260,8 @@ private function inspect (string $id, array $path, array &$issues):void { $contextual, $parameter, $path, - $issues + $issues, + $reported_cycles ); continue; @@ -296,7 +315,8 @@ private function inspect (string $id, array $path, array &$issues):void { $this->inspect( $dependency, $path, - $issues + $issues, + $reported_cycles ); } @@ -382,6 +402,7 @@ private function selectContextualDependency (string $consumer, ParameterPlan $pa * @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. @@ -398,10 +419,13 @@ private function selectContextualDependency (string $consumer, ParameterPlan $pa * @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):void { + private function inspectContextualDependency (string $consumer, string $dependency, ParameterPlan $parameter, array $path, array &$issues, array &$reported_cycles):void { if ( !$this->registry->has($dependency) @@ -426,44 +450,33 @@ private function inspectContextualDependency (string $consumer, string $dependen $type = $parameter->parameter->getType(); - if ($type !== null) { - - $metadata = new ConstructorMetadataCache; - - $matcher = new TypeMatcher($metadata); - - $selector = new \FireHub\Container\Internal\CandidateSelector( - $matcher, - fn (string $id):bool => $this->registry->has($id), - fn ():iterable => $this->registry->implementations() - ); - - if (!$selector->matchesType( + 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; + $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 + $issues, + $reported_cycles ); } @@ -524,4 +537,167 @@ private function implementationOf (string $id):?string { } + /** + * ### Resolves a canonical service identifier + * + * Follows registered service aliases without constructing services. + * + * Invalid and circular alias chains are ignored because they are validated separately by the main validator. + * @since 1.0.0 + * + * @uses \FireHub\Container\Registry::find() To retrieve service definitions. + * @uses \FireHub\Container\Registry::has() To check whether an alias target exists. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @return null|class-string Canonical service identifier, or null when the alias chain is invalid. + */ + private function canonicalId (string $id):?string { + + $visited = []; + + while (true) { + + if (isset($visited[$id])) + return null; + + $visited[$id] = true; + + $definition = $this->registry->find($id); + + if (!$definition instanceof Alias) + return $id; + + if (!$this->registry->has($definition->target)) + return null; + + $id = $definition->target; + + } + + } + + /** + * ### 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 + ); + + } + } \ No newline at end of file diff --git a/src/Validation/IssueType.php b/src/Validation/IssueType.php index 286b642..cc845fb 100644 --- a/src/Validation/IssueType.php +++ b/src/Validation/IssueType.php @@ -140,4 +140,12 @@ enum IssueType { */ 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; + } \ No newline at end of file diff --git a/tests/Unit/DependencyGraphValidationTest.php b/tests/Unit/DependencyGraphValidationTest.php new file mode 100644 index 0000000..2744907 --- /dev/null +++ b/tests/Unit/DependencyGraphValidationTest.php @@ -0,0 +1,595 @@ + + * @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,ContextualCircularA, ContextualCircularB, CollectionInjectionConflictingAttributesConsumer, + ContextualNamedConsumer, ContextualLogger, ContextualFileLogger, Logger, LoggerInterface, TestFileLogger, + ValidationConstructionProbe, ValidationFileLogger +}; +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 + ) + ); + + } + +} \ No newline at end of file From e5a0ac98987a215eee6fb8a2b712199c7e4155bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 10:24:57 +0200 Subject: [PATCH 05/15] Enhance dependency validation by adding TagRegistry support and improving constructor parameter handling --- src/Container.php | 3 +- src/Validation/DependencyGraph.php | 326 +++++++- src/Validator.php | 11 +- tests/Stubs/CollectionValidationCircular.php | 35 + tests/Stubs/CollectionValidationConsumer.php | 35 + tests/Stubs/CollectionValidationContract.php | 20 + tests/Stubs/CollectionValidationFirst.php | 20 + .../CollectionValidationMissingContract.php | 20 + .../CollectionValidationMissingDependency.php | 32 + tests/Stubs/CollectionValidationNested.php | 30 + tests/Stubs/CollectionValidationSecond.php | 20 + tests/Stubs/TaggedCircularConsumer.php | 35 + tests/Stubs/TaggedValidationConsumer.php | 35 + tests/Unit/DependencyGraphValidationTest.php | 758 +++++++++++++++++- 14 files changed, 1363 insertions(+), 17 deletions(-) create mode 100644 tests/Stubs/CollectionValidationCircular.php create mode 100644 tests/Stubs/CollectionValidationConsumer.php create mode 100644 tests/Stubs/CollectionValidationContract.php create mode 100644 tests/Stubs/CollectionValidationFirst.php create mode 100644 tests/Stubs/CollectionValidationMissingContract.php create mode 100644 tests/Stubs/CollectionValidationMissingDependency.php create mode 100644 tests/Stubs/CollectionValidationNested.php create mode 100644 tests/Stubs/CollectionValidationSecond.php create mode 100644 tests/Stubs/TaggedCircularConsumer.php create mode 100644 tests/Stubs/TaggedValidationConsumer.php diff --git a/src/Container.php b/src/Container.php index f7eaf0e..921a295 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1232,7 +1232,8 @@ public function validate ():Result { return new Validator( $this->registry, $this->parameters, - $this->contextual + $this->contextual, + $this->tags )->validate(); } diff --git a/src/Validation/DependencyGraph.php b/src/Validation/DependencyGraph.php index d155618..3f1a84f 100644 --- a/src/Validation/DependencyGraph.php +++ b/src/Validation/DependencyGraph.php @@ -19,12 +19,13 @@ }; use FireHub\Container\Internal\ { CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlan, - DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TypeMatcher + DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { AmbiguousDependencyException, ServiceInstantiationException }; use FireHub\Runtime; +use ReflectionIntersectionType, ReflectionUnionType; /** * ### Validates constructor dependency graphs @@ -89,13 +90,17 @@ * @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, - ContextualBindingRegistry $contextual + ContextualBindingRegistry $contextual, + private TagRegistry $tags ) { $metadata = new ConstructorMetadataCache; @@ -166,14 +171,22 @@ public function validate ():array { * A dependency is considered circular when its service identifier already appears in the active path. * @since 1.0.0 * - * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if a service identifier is already in the path. + * @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\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

@@ -189,6 +202,10 @@ public function validate ():array { * 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 { @@ -238,12 +255,34 @@ private function inspect (string $id, array $path, array &$issues, array &$repor $name = $parameter->parameter->getName(); - if ($this->bindings->has($class, $name)) + if ($this->bindings->has($class, $name)) continue; + + if ($parameter->tag !== null) { + + $this->inspectTaggedDependencies( + $parameter->tag, + $path, + $issues, + $reported_cycles + ); + continue; - if ($parameter->tag !== null || $parameter->collection !== null) + } + + if ($parameter->collection !== null) { + + $this->inspectCollectionDependencies( + $parameter->collection, + $path, + $issues, + $reported_cycles + ); + continue; + } + [$ambiguous, $contextual] = $this->selectContextualDependency( $class, $parameter, @@ -268,10 +307,18 @@ private function inspect (string $id, array $path, array &$issues, array &$repor } - if ($parameter->composite !== null) - continue; + if ($parameter->composite !== null) { + + [$ambiguous, $dependency] = $this->selectCompositeDependency( + $class, + $parameter, + $path, + $issues + ); - $dependency = $parameter->id; + if ($ambiguous) continue; + + } else $dependency = $parameter->id; if ($dependency === null) { @@ -700,4 +747,263 @@ private function reportCycle (string $id, array $path, array &$issues, array &$r } + /** + * ### 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/Validator.php b/src/Validator.php index 1c21b0c..31e8d0a 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -20,7 +20,7 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ContextualBindingRegistry, ParameterBindingRegistry + ContextualBindingRegistry, ParameterBindingRegistry, TagRegistry }; use FireHub\Runtime; use ReflectionClass, ReflectionException; @@ -57,13 +57,17 @@ * @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 ContextualBindingRegistry $contextual, + private TagRegistry $tags ) {} /** @@ -117,7 +121,8 @@ public function validate ():Result { $dependency_issues = new DependencyGraph( $this->registry, $this->bindings, - $this->contextual + $this->contextual, + $this->tags )->validate(); Runtime\Arr\Mutation::push($issues, ...$dependency_issues); 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/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/Unit/DependencyGraphValidationTest.php b/tests/Unit/DependencyGraphValidationTest.php index 2744907..b9cee7c 100644 --- a/tests/Unit/DependencyGraphValidationTest.php +++ b/tests/Unit/DependencyGraphValidationTest.php @@ -19,9 +19,14 @@ Issue, IssueType, Result, Severity }; use FireHub\Tests\Container\Stubs\ { - CircularA, CircularB,ContextualCircularA, ContextualCircularB, CollectionInjectionConflictingAttributesConsumer, - ContextualNamedConsumer, ContextualLogger, ContextualFileLogger, Logger, LoggerInterface, TestFileLogger, - ValidationConstructionProbe, ValidationFileLogger + CircularA, CircularB, CollectionInjectionConflictingAttributesConsumer, CollectionValidationCircular, + CollectionValidationConsumer, CollectionValidationContract, CollectionValidationFirst, + CollectionValidationMissingDependency, CollectionValidationNested, CollectionValidationSecond, ContextualCircularA, + ContextualCircularB, ContextualFileLogger, ContextualLogger, DnfCacheService, DnfDefaultConsumer, DnfFileLogger, + DnfFirstBranchConsumer, DnfNestedConsumer, DnfNoCandidateConsumer, DnfRequiredNullableConsumer, + DnfSecondBranchConsumer, DnfSerializableCache, DnfTwoIntersectionsConsumer, DnfUniversalService, + IntersectionConsumer, Logger, LoggerInterface, TaggedCircularConsumer, TaggedValidationConsumer, TestFileLogger, + TestLogger, UnionA, UnionAmbiguous, UnionB, UnionNullable, UnionOnlyA, ValidationConstructionProbe }; use PHPUnit\Framework\Attributes\ { CoversClass, Group, Small @@ -592,4 +597,751 @@ public function testCircularContextualDependency ():void { } + /** + * ### 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()); + + } + } \ No newline at end of file From 2b74e3443f1f5deb5e8a3507255ded6989fb8cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 10:40:33 +0200 Subject: [PATCH 06/15] Add ParameterBinding class for validating constructor parameter bindings --- src/Internal/ParameterBindingRegistry.php | 14 + src/Validation/ParameterBinding.php | 262 +++++++++++ src/Validator.php | 9 +- .../ParameterBindingValidationComposite.php | 36 ++ .../ParameterBindingValidationContract.php | 20 + .../ParameterBindingValidationService.php | 38 ++ tests/Unit/DependencyGraphValidationTest.php | 433 +++++++++++++++++- 7 files changed, 809 insertions(+), 3 deletions(-) create mode 100644 src/Validation/ParameterBinding.php create mode 100644 tests/Stubs/ParameterBindingValidationComposite.php create mode 100644 tests/Stubs/ParameterBindingValidationContract.php create mode 100644 tests/Stubs/ParameterBindingValidationService.php 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/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/Validator.php b/src/Validator.php index 31e8d0a..340be67 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -14,7 +14,7 @@ namespace FireHub\Container; use FireHub\Container\Validation\ { - DependencyGraph, Issue, IssueType, Result, Severity + DependencyGraph, Issue, IssueType, ParameterBinding, Result, Severity }; use FireHub\Container\Definition\ { Alias, Cls, Factory, Instance @@ -79,6 +79,7 @@ public function __construct ( * @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\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. @@ -127,6 +128,12 @@ public function validate ():Result { Runtime\Arr\Mutation::push($issues, ...$dependency_issues); + $binding_issues = new ParameterBinding( + $this->bindings + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$binding_issues); + /** @var list<\FireHub\Container\Validation\Issue> $issues */ return new Result($issues); 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/Unit/DependencyGraphValidationTest.php b/tests/Unit/DependencyGraphValidationTest.php index b9cee7c..3bf2bf0 100644 --- a/tests/Unit/DependencyGraphValidationTest.php +++ b/tests/Unit/DependencyGraphValidationTest.php @@ -25,8 +25,10 @@ ContextualCircularB, ContextualFileLogger, ContextualLogger, DnfCacheService, DnfDefaultConsumer, DnfFileLogger, DnfFirstBranchConsumer, DnfNestedConsumer, DnfNoCandidateConsumer, DnfRequiredNullableConsumer, DnfSecondBranchConsumer, DnfSerializableCache, DnfTwoIntersectionsConsumer, DnfUniversalService, - IntersectionConsumer, Logger, LoggerInterface, TaggedCircularConsumer, TaggedValidationConsumer, TestFileLogger, - TestLogger, UnionA, UnionAmbiguous, UnionB, UnionNullable, UnionOnlyA, ValidationConstructionProbe + IntersectionConsumer, Logger, LoggerInterface, ParameterBindingValidationComposite, + ParameterBindingValidationContract, ParameterBindingValidationService, TaggedCircularConsumer, + TaggedValidationConsumer, TestFileLogger, TestLogger, UnionA, UnionAmbiguous, UnionB, UnionNullable, UnionOnlyA, + ValidationConstructionProbe }; use PHPUnit\Framework\Attributes\ { CoversClass, Group, Small @@ -1344,4 +1346,431 @@ public function testNestedCollectionDependencyValidation ():void { } + /** + * ### 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 + ) + ); + + } + } \ No newline at end of file From 86171371d34fa4337b9176a70c38ee8f7cf696df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 12:15:24 +0200 Subject: [PATCH 07/15] Add ContextualBinding and validation for contextual dependency bindings --- src/Container.php | 3 + src/Internal/ContextualBindingRegistry.php | 16 + src/Validation/ContextualBinding.php | 359 ++++++++++++ src/Validation/IssueType.php | 11 + src/Validator.php | 9 +- .../ContextualValidationOtherContract.php | 20 + .../ContextualValidationUnusedConsumer.php | 26 + tests/Unit/DependencyGraphValidationTest.php | 551 +++++++++++++++++- 8 files changed, 985 insertions(+), 10 deletions(-) create mode 100644 src/Validation/ContextualBinding.php create mode 100644 tests/Stubs/ContextualValidationOtherContract.php create mode 100644 tests/Stubs/ContextualValidationUnusedConsumer.php diff --git a/src/Container.php b/src/Container.php index 921a295..e870b85 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1225,6 +1225,9 @@ public function isFrozen ():bool { * * @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. + * * @return \FireHub\Container\Validation\Result Container validation result. */ public function validate ():Result { 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/Validation/ContextualBinding.php b/src/Validation/ContextualBinding.php new file mode 100644 index 0000000..074ae71 --- /dev/null +++ b/src/Validation/ContextualBinding.php @@ -0,0 +1,359 @@ + + * @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\ { + ConstructorMetadataCache, ContextualBindingRegistry, DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, + ParameterPlanCompiler, TypeMatcher +}; +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. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry, + private ContextualBindingRegistry $bindings, + private ParameterBindingRegistry $parameters + ) { + + $metadata = new ConstructorMetadataCache; + + $this->plans = new DependencyPlanCache( + $metadata, + new ParameterPlanCompiler( + new TypeMatcher($metadata) + ) + ); + + } + + /** + * ### 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/IssueType.php b/src/Validation/IssueType.php index cc845fb..f12f6d1 100644 --- a/src/Validation/IssueType.php +++ b/src/Validation/IssueType.php @@ -148,4 +148,15 @@ enum IssueType { */ 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/Validator.php b/src/Validator.php index 340be67..f5ca88a 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -14,7 +14,7 @@ namespace FireHub\Container; use FireHub\Container\Validation\ { - DependencyGraph, Issue, IssueType, ParameterBinding, Result, Severity + ContextualBinding, DependencyGraph, Issue, IssueType, ParameterBinding, Result, Severity }; use FireHub\Container\Definition\ { Alias, Cls, Factory, Instance @@ -86,6 +86,9 @@ public function __construct ( * @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. + * * @return \FireHub\Container\Validation\Result Validation result. */ public function validate ():Result { @@ -128,7 +131,9 @@ public function validate ():Result { Runtime\Arr\Mutation::push($issues, ...$dependency_issues); - $binding_issues = new ParameterBinding( + $binding_issues = new ContextualBinding( + $this->registry, + $this->contextual, $this->bindings )->validate(); 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/Unit/DependencyGraphValidationTest.php b/tests/Unit/DependencyGraphValidationTest.php index 3bf2bf0..a02c344 100644 --- a/tests/Unit/DependencyGraphValidationTest.php +++ b/tests/Unit/DependencyGraphValidationTest.php @@ -21,14 +21,14 @@ use FireHub\Tests\Container\Stubs\ { CircularA, CircularB, CollectionInjectionConflictingAttributesConsumer, CollectionValidationCircular, CollectionValidationConsumer, CollectionValidationContract, CollectionValidationFirst, - CollectionValidationMissingDependency, CollectionValidationNested, CollectionValidationSecond, ContextualCircularA, - ContextualCircularB, ContextualFileLogger, ContextualLogger, DnfCacheService, DnfDefaultConsumer, DnfFileLogger, - DnfFirstBranchConsumer, DnfNestedConsumer, DnfNoCandidateConsumer, DnfRequiredNullableConsumer, - DnfSecondBranchConsumer, DnfSerializableCache, DnfTwoIntersectionsConsumer, DnfUniversalService, - IntersectionConsumer, Logger, LoggerInterface, ParameterBindingValidationComposite, - ParameterBindingValidationContract, ParameterBindingValidationService, TaggedCircularConsumer, - TaggedValidationConsumer, TestFileLogger, TestLogger, UnionA, UnionAmbiguous, UnionB, UnionNullable, UnionOnlyA, - ValidationConstructionProbe + 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 @@ -1773,4 +1773,539 @@ public function testMultipleInvalidParameterBindings ():void { } + /** + * ### 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 From aa360a735a03c5d180a25154f3ef9ba83cf236b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 12:21:34 +0200 Subject: [PATCH 08/15] Rename binding issues variable and update validation logic in Validator --- src/Validator.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Validator.php b/src/Validator.php index f5ca88a..11570af 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -131,13 +131,19 @@ public function validate ():Result { Runtime\Arr\Mutation::push($issues, ...$dependency_issues); - $binding_issues = new ContextualBinding( + $parameter_issues = new ParameterBinding( + $this->bindings + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$parameter_issues); + + $contextual_issues = new ContextualBinding( $this->registry, $this->contextual, $this->bindings )->validate(); - Runtime\Arr\Mutation::push($issues, ...$binding_issues); + Runtime\Arr\Mutation::push($issues, ...$contextual_issues); /** @var list<\FireHub\Container\Validation\Issue> $issues */ return new Result($issues); From a24a694ea892be6d0f9dbf138c4e63c7a9605de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 12:38:39 +0200 Subject: [PATCH 09/15] Add Lifetime validation classes and tests for service lifetime relationships --- src/Validation/Lifetime.php | 295 ++++++++++++++++++ src/Validator.php | 11 +- .../LifetimeValidationExplicitConsumer.php | 25 ++ .../LifetimeValidationFactoryConsumer.php | 25 ++ .../LifetimeValidationNestedSingleton.php | 25 ++ .../Stubs/LifetimeValidationSafeConsumer.php | 25 ++ .../Stubs/LifetimeValidationSafeSingleton.php | 21 ++ tests/Stubs/LifetimeValidationScoped.php | 21 ++ tests/Stubs/LifetimeValidationSingleton.php | 25 ++ tests/Stubs/LifetimeValidationTransient.php | 25 ++ tests/Unit/LifetimeValidationTest.php | 229 ++++++++++++++ 11 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 src/Validation/Lifetime.php create mode 100644 tests/Stubs/LifetimeValidationExplicitConsumer.php create mode 100644 tests/Stubs/LifetimeValidationFactoryConsumer.php create mode 100644 tests/Stubs/LifetimeValidationNestedSingleton.php create mode 100644 tests/Stubs/LifetimeValidationSafeConsumer.php create mode 100644 tests/Stubs/LifetimeValidationSafeSingleton.php create mode 100644 tests/Stubs/LifetimeValidationScoped.php create mode 100644 tests/Stubs/LifetimeValidationSingleton.php create mode 100644 tests/Stubs/LifetimeValidationTransient.php create mode 100644 tests/Unit/LifetimeValidationTest.php diff --git a/src/Validation/Lifetime.php b/src/Validation/Lifetime.php new file mode 100644 index 0000000..17a69be --- /dev/null +++ b/src/Validation/Lifetime.php @@ -0,0 +1,295 @@ + + * @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\ { + Alias, Cls, Factory, Instance +}; +use FireHub\Container\Internal\ { + ConstructorMetadataCache, DependencyPlanCache, ParameterBindingRegistry, ParameterPlanCompiler, TypeMatcher +}; +use FireHub\Container\Exception\ServiceInstantiationException; +use FireHub\Runtime; + +/** + * ### 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; + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Container\Registry $registry

+ * Registered service definitions. + *

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

+ * Explicit constructor parameter bindings. + *

+ * + * @return void + */ + public function __construct ( + private Registry $registry, + private ParameterBindingRegistry $bindings + ) { + + $metadata = new ConstructorMetadataCache; + + $this->matcher = new TypeMatcher($metadata); + + $this->plans = new DependencyPlanCache( + $metadata, + new ParameterPlanCompiler($this->matcher) + ); + + } + + /** + * ### 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. + * + * @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. + * + * @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. + *

+ * + * @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 || $parameter->collection !== null) + continue; + + if ($parameter->composite !== null) + continue; + + $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 the canonical service identifier + * + * Follows registered alias chains without resolving service instances. + * + * Invalid or circular aliases are ignored because they are reported by the existing service definition + * validator. + * @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. + * + * @param class-string $id

+ * Service identifier. + *

+ * + * @return null|class-string Canonical service identifier. + */ + private function canonicalId (string $id):?string { + + $visited = []; + + while (true) { + + if (isset($visited[$id])) return null; + + $visited[$id] = true; + + $definition = $this->registry->find($id); + + if (!$definition instanceof Alias) + return $id; + + $id = $definition->target; + + if (!$this->registry->has($id)) + return null; + + } + + } + +} \ No newline at end of file diff --git a/src/Validator.php b/src/Validator.php index 11570af..474fd86 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -14,7 +14,7 @@ namespace FireHub\Container; use FireHub\Container\Validation\ { - ContextualBinding, DependencyGraph, Issue, IssueType, ParameterBinding, Result, Severity + ContextualBinding, DependencyGraph, Issue, IssueType, Lifetime, ParameterBinding, Result, Severity }; use FireHub\Container\Definition\ { Alias, Cls, Factory, Instance @@ -80,6 +80,8 @@ public function __construct ( * * @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. @@ -145,6 +147,13 @@ public function validate ():Result { Runtime\Arr\Mutation::push($issues, ...$contextual_issues); + $lifetime_issues = new Lifetime( + $this->registry, + $this->bindings + )->validate(); + + Runtime\Arr\Mutation::push($issues, ...$lifetime_issues); + /** @var list<\FireHub\Container\Validation\Issue> $issues */ return new Result($issues); 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/Unit/LifetimeValidationTest.php b/tests/Unit/LifetimeValidationTest.php new file mode 100644 index 0000000..2bd15c7 --- /dev/null +++ b/tests/Unit/LifetimeValidationTest.php @@ -0,0 +1,229 @@ + + * @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\ { + LifetimeValidationExplicitConsumer, LifetimeValidationFactoryConsumer, LifetimeValidationNestedSingleton, + LifetimeValidationSafeConsumer, LifetimeValidationSafeSingleton, LifetimeValidationScoped, + LifetimeValidationSingleton, LifetimeValidationTransient +}; +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); + + } + +} \ No newline at end of file From a7d3f05d11db60a8ce9607e7b4b3d4715fbc7167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 12:51:18 +0200 Subject: [PATCH 10/15] Enhance dependency validation by adding contextual and composite candidate selectors --- src/Container.php | 2 + src/Validation/Lifetime.php | 313 +++++++++++++++++++++++++- src/Validator.php | 6 +- tests/Unit/LifetimeValidationTest.php | 195 +++++++++++++++- 4 files changed, 505 insertions(+), 11 deletions(-) diff --git a/src/Container.php b/src/Container.php index e870b85..079bb9a 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1227,6 +1227,8 @@ public function isFrozen ():bool { * * @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. */ diff --git a/src/Validation/Lifetime.php b/src/Validation/Lifetime.php index 17a69be..9ae9e46 100644 --- a/src/Validation/Lifetime.php +++ b/src/Validation/Lifetime.php @@ -19,10 +19,14 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ConstructorMetadataCache, DependencyPlanCache, ParameterBindingRegistry, ParameterPlanCompiler, TypeMatcher + CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, + DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TagRegistry, TypeMatcher +}; +use FireHub\Container\Exception\ { + AmbiguousDependencyException, ServiceInstantiationException }; -use FireHub\Container\Exception\ServiceInstantiationException; use FireHub\Runtime; +use ReflectionIntersectionType, ReflectionUnionType; /** * ### Validates service lifetime relationships @@ -55,6 +59,18 @@ */ 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 @@ -65,12 +81,20 @@ * @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 ParameterBindingRegistry $bindings, + ContextualBindingRegistry $contextual, + private TagRegistry $tags ) { $metadata = new ConstructorMetadataCache; @@ -82,6 +106,16 @@ public function __construct ( new ParameterPlanCompiler($this->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() + ); + } /** @@ -95,6 +129,9 @@ public function __construct ( * @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 { @@ -134,6 +171,10 @@ public function validate ():array { * @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. @@ -151,6 +192,9 @@ public function validate ():array { * 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 { @@ -221,22 +265,75 @@ private function inspect (string $root, string $id, array $path, array &$issues, if ($this->bindings->has($class, $name)) continue; - if ($parameter->tag !== null || $parameter->collection !== null) + if ($parameter->tag !== null) { + + $this->inspectTagged( + $root, + $parameter->tag, + $path, + $issues, + $reported + ); + continue; - if ($parameter->composite !== null) + } + + if ($parameter->collection !== null) { + + $this->inspectCollection( + $root, + $parameter->collection, + $path, + $issues, + $reported + ); + continue; - $dependency = $parameter->id; + } + + try { + + $dependency = $this->contextual_selector->select( + $class, + $parameter + ); + + } catch (AmbiguousDependencyException) { - if ($dependency === null) 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; + ) continue; $this->inspect( $root, @@ -292,4 +389,202 @@ private function canonicalId (string $id):?string { } + /** + * ### 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/Validator.php b/src/Validator.php index 474fd86..2c8ea0a 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -90,6 +90,8 @@ public function __construct ( * * @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. */ @@ -149,7 +151,9 @@ public function validate ():Result { $lifetime_issues = new Lifetime( $this->registry, - $this->bindings + $this->bindings, + $this->contextual, + $this->tags )->validate(); Runtime\Arr\Mutation::push($issues, ...$lifetime_issues); diff --git a/tests/Unit/LifetimeValidationTest.php b/tests/Unit/LifetimeValidationTest.php index 2bd15c7..47c9d6b 100644 --- a/tests/Unit/LifetimeValidationTest.php +++ b/tests/Unit/LifetimeValidationTest.php @@ -19,9 +19,11 @@ }; 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 + LifetimeValidationSingleton, LifetimeValidationTransient, TaggedFileLogger, TaggedValidationConsumer }; use PHPUnit\Framework\Attributes\ { CoversClass, Group, Small @@ -226,4 +228,195 @@ static function () use (&$executions):LifetimeValidationFactoryConsumer { } + /** + * ### 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 From 2e8c726f7d45df2a559e2cbbda33bda170c1ec13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 12:53:09 +0200 Subject: [PATCH 11/15] Add unit tests for runtime service lifetime protection in Container --- tests/Unit/LifetimeRuntimeTest.php | 145 +++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/Unit/LifetimeRuntimeTest.php 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 From 462dc70a1c0dbe77396a3bf44a99fbb35d066fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 13:04:20 +0200 Subject: [PATCH 12/15] Refactor dependency injection in container classes to streamline parameter handling --- src/Validation/ContextualBinding.php | 18 +++++++----------- src/Validation/DependencyGraph.php | 23 +++++++++++++---------- src/Validation/Lifetime.php | 19 +++++++++++-------- src/Validator.php | 25 ++++++++++++++++++++----- 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/src/Validation/ContextualBinding.php b/src/Validation/ContextualBinding.php index 074ae71..aea384c 100644 --- a/src/Validation/ContextualBinding.php +++ b/src/Validation/ContextualBinding.php @@ -18,8 +18,7 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ConstructorMetadataCache, ContextualBindingRegistry, DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, - ParameterPlanCompiler, TypeMatcher + ContextualBindingRegistry, DependencyPlanCache, ParameterBindingRegistry }; use FireHub\Container\Exception\ServiceInstantiationException; use FireHub\Runtime; @@ -57,23 +56,20 @@ * @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 + private ParameterBindingRegistry $parameters, + DependencyPlanCache $plans ) { - $metadata = new ConstructorMetadataCache; - - $this->plans = new DependencyPlanCache( - $metadata, - new ParameterPlanCompiler( - new TypeMatcher($metadata) - ) - ); + $this->plans = $plans; } diff --git a/src/Validation/DependencyGraph.php b/src/Validation/DependencyGraph.php index 3f1a84f..7be7742 100644 --- a/src/Validation/DependencyGraph.php +++ b/src/Validation/DependencyGraph.php @@ -18,8 +18,8 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlan, - DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TagRegistry, TypeMatcher + CandidateSelector, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlan, DependencyPlanCache, + ParameterBindingRegistry, ParameterPlan, TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { AmbiguousDependencyException, ServiceInstantiationException @@ -93,6 +93,12 @@ * @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. + *

* * @return void */ @@ -100,17 +106,14 @@ public function __construct ( private Registry $registry, private ParameterBindingRegistry $bindings, ContextualBindingRegistry $contextual, - private TagRegistry $tags + private TagRegistry $tags, + DependencyPlanCache $plans, + TypeMatcher $matcher ) { - $metadata = new ConstructorMetadataCache; + $this->plans = $plans; - $this->matcher = new TypeMatcher($metadata); - - $this->plans = new DependencyPlanCache( - $metadata, - new ParameterPlanCompiler($this->matcher) - ); + $this->matcher = $matcher; $this->contextual_selector = new ContextualCandidateSelector( $contextual diff --git a/src/Validation/Lifetime.php b/src/Validation/Lifetime.php index 9ae9e46..94ff67f 100644 --- a/src/Validation/Lifetime.php +++ b/src/Validation/Lifetime.php @@ -87,6 +87,12 @@ * @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. + *

* * @return void */ @@ -94,17 +100,14 @@ public function __construct ( private Registry $registry, private ParameterBindingRegistry $bindings, ContextualBindingRegistry $contextual, - private TagRegistry $tags + private TagRegistry $tags, + DependencyPlanCache $plans, + TypeMatcher $matcher ) { - $metadata = new ConstructorMetadataCache; + $this->plans = $plans; - $this->matcher = new TypeMatcher($metadata); - - $this->plans = new DependencyPlanCache( - $metadata, - new ParameterPlanCompiler($this->matcher) - ); + $this->matcher = $matcher; $this->contextual_selector = new ContextualCandidateSelector( $contextual diff --git a/src/Validator.php b/src/Validator.php index 2c8ea0a..3629f6b 100644 --- a/src/Validator.php +++ b/src/Validator.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.2 + * @php-version >=8.5 * @package Container */ @@ -20,7 +20,8 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ContextualBindingRegistry, ParameterBindingRegistry, TagRegistry + ConstructorMetadataCache, ContextualBindingRegistry, DependencyPlanCache, ParameterBindingRegistry, + ParameterPlanCompiler, TagRegistry, TypeMatcher }; use FireHub\Runtime; use ReflectionClass, ReflectionException; @@ -97,6 +98,15 @@ public function __construct ( */ 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) { @@ -130,7 +140,9 @@ public function validate ():Result { $this->registry, $this->bindings, $this->contextual, - $this->tags + $this->tags, + $plans, + $matcher )->validate(); Runtime\Arr\Mutation::push($issues, ...$dependency_issues); @@ -144,7 +156,8 @@ public function validate ():Result { $contextual_issues = new ContextualBinding( $this->registry, $this->contextual, - $this->bindings + $this->bindings, + $plans )->validate(); Runtime\Arr\Mutation::push($issues, ...$contextual_issues); @@ -153,7 +166,9 @@ public function validate ():Result { $this->registry, $this->bindings, $this->contextual, - $this->tags + $this->tags, + $plans, + $matcher )->validate(); Runtime\Arr\Mutation::push($issues, ...$lifetime_issues); From 05fcdef2eeed24558c31939b5c1be1c16575032e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 13:14:51 +0200 Subject: [PATCH 13/15] Add CanonicalIdCache for resolving and caching service identifiers --- src/Internal/CanonicalIdCache.php | 132 +++++++++++++++++++++++++++++ src/Validation/DependencyGraph.php | 40 +++------ src/Validation/Lifetime.php | 42 +++------ src/Validator.php | 14 ++- 4 files changed, 163 insertions(+), 65 deletions(-) create mode 100644 src/Internal/CanonicalIdCache.php 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/Validation/DependencyGraph.php b/src/Validation/DependencyGraph.php index 7be7742..8293b64 100644 --- a/src/Validation/DependencyGraph.php +++ b/src/Validation/DependencyGraph.php @@ -18,8 +18,8 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - CandidateSelector, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlan, DependencyPlanCache, - ParameterBindingRegistry, ParameterPlan, TagRegistry, TypeMatcher + CandidateSelector, CanonicalIdCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlan, + DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { AmbiguousDependencyException, ServiceInstantiationException @@ -99,6 +99,9 @@ * @param \FireHub\Container\Internal\TypeMatcher $matcher

* Dependency type matcher. *

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

+ * Canonical service identifier cache. + *

* * @return void */ @@ -108,7 +111,8 @@ public function __construct ( ContextualBindingRegistry $contextual, private TagRegistry $tags, DependencyPlanCache $plans, - TypeMatcher $matcher + TypeMatcher $matcher, + private CanonicalIdCache $canonical ) { $this->plans = $plans; @@ -589,43 +593,19 @@ private function implementationOf (string $id):?string { /** * ### Resolves a canonical service identifier - * - * Follows registered service aliases without constructing services. - * - * Invalid and circular alias chains are ignored because they are validated separately by the main validator. * @since 1.0.0 * - * @uses \FireHub\Container\Registry::find() To retrieve service definitions. - * @uses \FireHub\Container\Registry::has() To check whether an alias target exists. + * @uses \FireHub\Container\Internal\Canonical::get() To retrieve the canonical identifier. * * @param class-string $id

* Service identifier. *

* - * @return null|class-string Canonical service identifier, or null when the alias chain is invalid. + * @return null|class-string Canonical service identifier. */ private function canonicalId (string $id):?string { - $visited = []; - - while (true) { - - if (isset($visited[$id])) - return null; - - $visited[$id] = true; - - $definition = $this->registry->find($id); - - if (!$definition instanceof Alias) - return $id; - - if (!$this->registry->has($definition->target)) - return null; - - $id = $definition->target; - - } + return $this->canonical->get($id); } diff --git a/src/Validation/Lifetime.php b/src/Validation/Lifetime.php index 94ff67f..8031c8b 100644 --- a/src/Validation/Lifetime.php +++ b/src/Validation/Lifetime.php @@ -16,11 +16,11 @@ use FireHub\Core\Meta\Enum\Container\Lifetime as LifetimeService; use FireHub\Container\Registry; use FireHub\Container\Definition\ { - Alias, Cls, Factory, Instance + Cls, Factory, Instance }; use FireHub\Container\Internal\ { - CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector, - DependencyPlanCache, ParameterBindingRegistry, ParameterPlan, ParameterPlanCompiler, TagRegistry, TypeMatcher + CandidateSelector, CanonicalIdCache, ContextualBindingRegistry, ContextualCandidateSelector, DependencyPlanCache, + ParameterBindingRegistry, ParameterPlan, TagRegistry, TypeMatcher }; use FireHub\Container\Exception\ { AmbiguousDependencyException, ServiceInstantiationException @@ -93,6 +93,9 @@ * @param \FireHub\Container\Internal\TypeMatcher $matcher

* Dependency type matcher. *

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

+ * Canonical service identifier cache. + *

* * @return void */ @@ -102,7 +105,8 @@ public function __construct ( ContextualBindingRegistry $contextual, private TagRegistry $tags, DependencyPlanCache $plans, - TypeMatcher $matcher + TypeMatcher $matcher, + private CanonicalIdCache $canonical ) { $this->plans = $plans; @@ -351,16 +355,10 @@ private function inspect (string $root, string $id, array $path, array &$issues, } /** - * ### Resolves the canonical service identifier - * - * Follows registered alias chains without resolving service instances. - * - * Invalid or circular aliases are ignored because they are reported by the existing service definition - * validator. + * ### Resolves a canonical service identifier * @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\Container\Internal\Canonical::get() To retrieve the canonical identifier. * * @param class-string $id

* Service identifier. @@ -370,25 +368,7 @@ private function inspect (string $root, string $id, array $path, array &$issues, */ private function canonicalId (string $id):?string { - $visited = []; - - while (true) { - - if (isset($visited[$id])) return null; - - $visited[$id] = true; - - $definition = $this->registry->find($id); - - if (!$definition instanceof Alias) - return $id; - - $id = $definition->target; - - if (!$this->registry->has($id)) - return null; - - } + return $this->canonical->get($id); } diff --git a/src/Validator.php b/src/Validator.php index 3629f6b..7ab9176 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -20,8 +20,8 @@ Alias, Cls, Factory, Instance }; use FireHub\Container\Internal\ { - ConstructorMetadataCache, ContextualBindingRegistry, DependencyPlanCache, ParameterBindingRegistry, - ParameterPlanCompiler, TagRegistry, TypeMatcher + CanonicalIdCache, ConstructorMetadataCache, ContextualBindingRegistry, DependencyPlanCache, + ParameterBindingRegistry, ParameterPlanCompiler, TagRegistry, TypeMatcher }; use FireHub\Runtime; use ReflectionClass, ReflectionException; @@ -136,13 +136,18 @@ public function validate ():Result { } + $canonical = new CanonicalIdCache( + $this->registry + ); + $dependency_issues = new DependencyGraph( $this->registry, $this->bindings, $this->contextual, $this->tags, $plans, - $matcher + $matcher, + $canonical )->validate(); Runtime\Arr\Mutation::push($issues, ...$dependency_issues); @@ -168,7 +173,8 @@ public function validate ():Result { $this->contextual, $this->tags, $plans, - $matcher + $matcher, + $canonical )->validate(); Runtime\Arr\Mutation::push($issues, ...$lifetime_issues); From 6e7201d6c257db22ea4fe2acb0ad8aed14f0ace0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Fri, 23 Oct 2026 13:20:50 +0200 Subject: [PATCH 14/15] Update CHANGELOG and README for version 0.4.0 release --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) 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..9c1f64b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /> version From 8d1015968bba28a445e80a7d5aa5f4c9b46bb458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= <56673130+dieselxxx@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:22:06 +0200 Subject: [PATCH 15/15] Revise README for clarity on repository purpose Updated README to clarify repository purpose and development branch details. --- README.md | 86 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 9c1f64b..ac570b7 100644 --- a/README.md +++ b/README.md @@ -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).