From d95196f9cffc0340d026c59a7fe457b8e97025f7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:58 +0000 Subject: [PATCH 01/12] Expose compiled aspect chain invalidation Add AspectManager::clear() as the explicit operation for discarding compiled aspect chains, and delegate flushState() to it. Rule registries can invalidate derived chains when configuration changes without using the test cleanup API. Document the worker-wide lifetime and restrict this operation to bootstrap or tests. Leave chain lookup, storage, and dispatch unchanged. Validation: full static analysis and the AOP dispatcher suite passed with the completed registration changes. --- src/di/src/Aop/AspectManager.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/di/src/Aop/AspectManager.php b/src/di/src/Aop/AspectManager.php index 835c244a42..3eeee0d7fc 100644 --- a/src/di/src/Aop/AspectManager.php +++ b/src/di/src/Aop/AspectManager.php @@ -36,11 +36,22 @@ public static function set(string $class, string $method, Closure $value): void static::$chains[$class][$method] = $value; } + /** + * Clear the compiled aspect chains. + * + * Boot or tests only. Clearing this worker-wide cache makes subsequent + * calls compile their chains from the current aspect rules. + */ + public static function clear(): void + { + static::$chains = []; + } + /** * Flush all static state. */ public static function flushState(): void { - static::$chains = []; + static::clear(); } } From 3e9cb3037b67232a81f635e4dc94c3511ba21282 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:58 +0000 Subject: [PATCH 02/12] Guard sealed aspect registration and invalidate changed chains Add explicit seal() and unseal() operations to the aspect collector. Reject registrations before mutating sealed rules, with an error directing callers to service provider register(). Reopening preserves existing rules; flushing restores an empty, open registry. Clear compiled chains whenever rules are added, reprioritized, removed, or reset so focused tests cannot reuse a pipeline compiled from old registrations. Keep all new checks at registration time, with no additions to method dispatch. Cover both registration entry points, rejection without mutation, reopening, cleanup, and dispatch results after each kind of rule change. Preserve the existing cache-reuse assertion for unchanged rules. Validation: collector and dispatcher tests, formatting, and full static analysis passed. --- src/di/src/Aop/AspectCollector.php | 55 ++++++++++++++++++++++------ tests/Di/Aop/AspectCollectorTest.php | 47 ++++++++++++++++++++++++ tests/Di/Aop/ProxyDispatcherTest.php | 31 +++++++++++++++- 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/src/di/src/Aop/AspectCollector.php b/src/di/src/Aop/AspectCollector.php index b2e6b2b3f8..61445f6c5c 100644 --- a/src/di/src/Aop/AspectCollector.php +++ b/src/di/src/Aop/AspectCollector.php @@ -4,16 +4,12 @@ namespace Hypervel\Di\Aop; +use Hypervel\Di\Exceptions\InvalidDefinitionException; use Hypervel\Support\ClassMetadataCache; use ReflectionProperty; /** - * Static registry of aspect class rules and priorities. - * - * Tracks which aspect classes target which classes/methods, - * and their execution priority. Used by ProxyManager to determine - * which classes need proxy generation and by ProxyDispatcher at runtime - * to resolve the aspect pipeline for each method call. + * Worker-wide aspect rules used to generate proxies and compile dispatch chains. */ class AspectCollector { @@ -24,10 +20,13 @@ class AspectCollector */ protected static array $aspectRules = []; + protected static bool $sealed = false; + /** * Register an aspect using its public default targeting rules and priority. * - * Boot-only. Register before proxy generation; loaded classes cannot be rewritten. + * Boot or tests only. Register before application proxy generation, or explicitly + * reopen registration for focused tests. Loaded classes cannot be rewritten. * * @param class-string $aspect */ @@ -53,18 +52,23 @@ public static function register(string $aspect): void /** * Register an aspect with its class targeting rules. * - * Boot-only. Aspect rules persist in a static property used by the + * Boot or tests only. Aspect rules persist in a static property used by the * compile-time proxy generator; runtime use cannot retroactively apply * aspects to already-loaded classes. */ public static function setAround(string $aspect, array $classes, ?int $priority = null): void { + if (static::$sealed) { + throw new InvalidDefinitionException( + "Cannot register aspect [{$aspect}] after proxy generation. " + . 'Register aspects in a service provider\'s register() method, before boot().' + ); + } + $priority ??= 0; - // Merge idempotently: a provider re-registering the same aspect on a repeated - // boot in the same worker must not append duplicate class rules; the proxy - // generator scans every rule against the whole class map, so duplicates make - // each boot slower than the last. + // Repeated boots re-register aspects. Duplicate rules would change the shared + // fingerprint, regenerating every proxy, and wildcards would rescan the class map. $existing = static::$aspectRules[$aspect]['classes'] ?? []; $classes = array_values(array_unique(array_merge($existing, $classes))); @@ -72,6 +76,29 @@ public static function setAround(string $aspect, array $classes, ?int $priority 'priority' => $priority, 'classes' => $classes, ]; + + AspectManager::clear(); + } + + /** + * Close aspect registration after application proxy generation. + * + * Boot-only. Later registrations in this worker are rejected. + */ + public static function seal(): void + { + static::$sealed = true; + } + + /** + * Reopen aspect registration while retaining the registered rules. + * + * Boot or tests only. This permits changes to worker-wide rules for a new + * application bootstrap or focused tests; it does not rewrite loaded classes. + */ + public static function unseal(): void + { + static::$sealed = false; } /** @@ -91,6 +118,8 @@ public static function hasAspects(): bool public static function forgetAspect(string $aspect): void { unset(static::$aspectRules[$aspect]); + + AspectManager::clear(); } /** @@ -143,5 +172,7 @@ public static function getRules(): array public static function flushState(): void { static::$aspectRules = []; + static::unseal(); + AspectManager::clear(); } } diff --git a/tests/Di/Aop/AspectCollectorTest.php b/tests/Di/Aop/AspectCollectorTest.php index 8c82358742..3cf4ff8e92 100644 --- a/tests/Di/Aop/AspectCollectorTest.php +++ b/tests/Di/Aop/AspectCollectorTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Di\Aop; use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Di\Exceptions\InvalidDefinitionException; +use Hypervel\Tests\Di\Fixtures\Aspect\TestAspect; use Hypervel\Tests\TestCase; class AspectCollectorTest extends TestCase @@ -69,6 +71,51 @@ public function testGetPriorityReturnsZeroForUnregisteredAspect(): void $this->assertSame(0, AspectCollector::getPriority('NonExistent')); } + public function testSealedCollectorRejectsRegistration(): void + { + AspectCollector::seal(); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessageIsOrContains('register()'); + + AspectCollector::register(TestAspect::class); + } + + public function testSealedCollectorRejectsRuleChanges(): void + { + AspectCollector::setAround(TestAspect::class, ['App\Foo::bar'], 5); + $rules = AspectCollector::getRules(); + AspectCollector::seal(); + + try { + AspectCollector::setAround(TestAspect::class, ['App\Baz::qux'], 10); + $this->fail('Sealed aspect rules must reject changes.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString(TestAspect::class, $exception->getMessage()); + $this->assertSame($rules, AspectCollector::getRules()); + } + } + + public function testUnsealPreservesRulesAndAllowsRegistration(): void + { + AspectCollector::setAround('FirstAspect', ['App\Foo::bar']); + AspectCollector::seal(); + AspectCollector::unseal(); + AspectCollector::setAround('SecondAspect', ['App\Foo::bar']); + + $this->assertSame(['FirstAspect', 'SecondAspect'], array_keys(AspectCollector::getRules())); + } + + public function testFlushStateReopensRegistration(): void + { + AspectCollector::setAround('FirstAspect', ['App\Foo::bar']); + AspectCollector::seal(); + AspectCollector::flushState(); + AspectCollector::register(TestAspect::class); + + $this->assertSame([TestAspect::class], array_keys(AspectCollector::getRules())); + } + public function testGetRuleReturnsEmptyForUnregisteredAspect(): void { $this->assertSame([], AspectCollector::getRule('NonExistent')); diff --git a/tests/Di/Aop/ProxyDispatcherTest.php b/tests/Di/Aop/ProxyDispatcherTest.php index 50ac9b8164..0b39c7b978 100644 --- a/tests/Di/Aop/ProxyDispatcherTest.php +++ b/tests/Di/Aop/ProxyDispatcherTest.php @@ -59,8 +59,6 @@ public function testPublishesAndReusesTheCompletePrioritizedAspectChain(): void )); $chain = AspectManager::get(ProxyDispatcherTarget::class, 'number'); - AspectCollector::flushState(); - $this->assertSame(5, ProxyDispatcher::dispatch( ProxyDispatcherTarget::class, 'number', @@ -71,6 +69,35 @@ public function testPublishesAndReusesTheCompletePrioritizedAspectChain(): void $this->assertSame($chain, AspectManager::get(ProxyDispatcherTarget::class, 'number')); } + public function testRuleChangesInvalidatePreviouslyCompiledChains(): void + { + $target = new ProxyDispatcherTarget; + $dispatch = fn (): mixed => ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 2]), + $target->number(...), + $target + ); + + $this->assertSame(2, $dispatch()); + + AspectCollector::setAround(DispatcherIncrementAspect::class, [ProxyDispatcherTarget::class . '::number'], 20); + $this->assertSame(3, $dispatch()); + + AspectCollector::setAround(DispatcherDoubleAspect::class, [ProxyDispatcherTarget::class . '::number'], 10); + $this->assertSame(5, $dispatch()); + + AspectCollector::setAround(DispatcherIncrementAspect::class, [ProxyDispatcherTarget::class . '::number'], 5); + $this->assertSame(6, $dispatch()); + + AspectCollector::forgetAspect(DispatcherIncrementAspect::class); + $this->assertSame(4, $dispatch()); + + AspectCollector::flushState(); + $this->assertSame(2, $dispatch()); + } + public function testAspectMayShortCircuitWithoutImplementingAroundInterface(): void { AspectCollector::setAround(NoProcessAspect::class, [ProxyDispatcherTarget::class . '::number']); From 75861c1b12b39f5783f3514a4eb3b403ec500608 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:58 +0000 Subject: [PATCH 03/12] Add a registration seal for class map overrides Give class map registration explicit seal() and unseal() operations. Reject late additions before touching registered entries or the Composer autoloader, directing callers to provider register(). Reopening retains existing overrides for repeated application bootstrap. Static cleanup removes overrides and returns registration to its initial open state. This prevents late replacements from bypassing generated proxies or being silently ignored because of autoloader ordering. Test that rejected additions preserve actual autoload behavior, reopening retains entries, and cleanup permits fresh registration. Validation: the class map test file, formatting, and full static analysis passed. --- src/di/src/ClassMap/ClassMapManager.php | 33 ++++++++++++++++- tests/Di/ClassMap/ClassMapManagerTest.php | 43 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/di/src/ClassMap/ClassMapManager.php b/src/di/src/ClassMap/ClassMapManager.php index 749e67011c..fe85db97b2 100644 --- a/src/di/src/ClassMap/ClassMapManager.php +++ b/src/di/src/ClassMap/ClassMapManager.php @@ -25,6 +25,8 @@ class ClassMapManager protected static ?ClassLoader $loader = null; + protected static bool $sealed = false; + /** * Add class map entries and apply them to the Composer autoloader. * @@ -32,12 +34,19 @@ class ClassMapManager * Fails if a target is already loaded from a different source. * * Boot-only. Class-map overrides mutate the worker's Composer autoloader and - * must be registered before any target class is autoloaded. + * must be registered before application proxy generation and target loading. * * @param array $map */ public static function add(array $map): void { + if (static::$sealed) { + throw new RuntimeException( + 'Cannot register class map overrides after proxy generation. ' + . 'Register overrides in a service provider\'s register() method, before boot().' + ); + } + foreach ($map as $class => $path) { if (class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false)) { $reflection = new ReflectionClass($class); @@ -67,6 +76,27 @@ public static function add(array $map): void static::$loader->addClassMap($map); } + /** + * Close class map registration after application proxy generation. + * + * Boot-only. Later registrations in this worker are rejected. + */ + public static function seal(): void + { + static::$sealed = true; + } + + /** + * Reopen class map registration while retaining the registered overrides. + * + * Boot or tests only. This permits worker-wide autoloader changes for a new + * application bootstrap; already-loaded classes cannot be replaced. + */ + public static function unseal(): void + { + static::$sealed = false; + } + /** * Publish generated replacements with the lifetime of their source overrides. * @@ -105,5 +135,6 @@ public static function flushState(): void static::$loader?->unregister(); static::$loader = null; static::$entries = []; + static::unseal(); } } diff --git a/tests/Di/ClassMap/ClassMapManagerTest.php b/tests/Di/ClassMap/ClassMapManagerTest.php index bf7f5a24da..b81d67bcbd 100644 --- a/tests/Di/ClassMap/ClassMapManagerTest.php +++ b/tests/Di/ClassMap/ClassMapManagerTest.php @@ -88,6 +88,49 @@ public function testLoadedReplacementCanBeRegisteredAgainAfterReset(): void $this->assertSame('repeated', (new $class)->value()); } + public function testSealedRegistryRejectsOverridesWithoutChangingAutoloading(): void + { + $class = __NAMESPACE__ . '\Sealed' . bin2hex(random_bytes(4)); + $original = $this->directory . '/Original.php'; + $replacement = $this->directory . '/Replacement.php'; + $this->writeSource($original, $class, 'original'); + $this->writeSource($replacement, $class, 'replacement'); + $this->isolatedLoader->addClassMap([$class => $original]); + ClassMapManager::seal(); + + try { + ClassMapManager::add([$class => $replacement]); + $this->fail('Sealed class maps must reject overrides.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('register()', $exception->getMessage()); + $this->assertSame([], ClassMapManager::getEntries()); + $this->assertSame('original', (new $class)->value()); + } + } + + public function testUnsealPreservesOverridesAndAllowsRegistration(): void + { + ClassMapManager::add(['Fake\First' => '/tmp/first.php']); + ClassMapManager::seal(); + ClassMapManager::unseal(); + ClassMapManager::add(['Fake\Second' => '/tmp/second.php']); + + $this->assertSame([ + 'Fake\First' => '/tmp/first.php', + 'Fake\Second' => '/tmp/second.php', + ], ClassMapManager::getEntries()); + } + + public function testFlushStateReopensRegistration(): void + { + ClassMapManager::add(['Fake\First' => '/tmp/first.php']); + ClassMapManager::seal(); + ClassMapManager::flushState(); + ClassMapManager::add(['Fake\Second' => '/tmp/second.php']); + + $this->assertSame(['Fake\Second' => '/tmp/second.php'], ClassMapManager::getEntries()); + } + public function testAddThrowsWhenClassAlreadyLoaded(): void { // This test class itself is already loaded From d9d161d46cdde8c8d2af48fdc01bf3ef47aa8713 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:58 +0000 Subject: [PATCH 04/12] Make focused AOP tests reopen registration explicitly Document the explicit AspectCollector::unseal() step for tests that register aspects after their application has booted and invoke them through callWithAspects(). Reopen registration in the helper test fixture after parent setup. Keep the helper itself free of automatic registration bypasses so ordinary provider boot methods remain subject to the same lifecycle rules in tests and production. Validation: the InteractsWithAop test file passed, including its existing interception and already-proxied object coverage; formatting and full static analysis passed. --- .../src/Testing/Concerns/InteractsWithAop.php | 4 ++++ .../Testing/Concerns/InteractsWithAopTest.php | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAop.php b/src/foundation/src/Testing/Concerns/InteractsWithAop.php index 2d9c791728..dd58389b85 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAop.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAop.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\ProxyDispatcher; use Hypervel\Di\Aop\ProxyMarker; use LogicException; @@ -43,6 +44,9 @@ protected function isAopProxied(object $instance): bool * The original method is called via reflection at the end of the pipeline. * The instance must not already be proxied. * + * After application bootstrap, call {@see AspectCollector::unseal()} before + * registering aspects for this helper. + * * @param object $instance The object instance to call the method on * @param string $method The method name to intercept * @param array $arguments Method arguments as key => value pairs (parameter names as keys) diff --git a/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php b/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php index dbf9e73b87..fb3d354b20 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php @@ -18,6 +18,16 @@ class InteractsWithAopTest extends TestCase { use InteractsWithAop; + /** + * Allow focused aspect registration after the test application has booted. + */ + protected function setUp(): void + { + parent::setUp(); + + AspectCollector::unseal(); + } + public function testCallWithAspectsRunsMatchingAspect(): void { AspectCollector::setAround(UppercaseGreetingAspect::class, [InteractsWithAopTarget::class . '::greet']); From e1cbf83a76b44c5f13d6b20b597eae411d519e34 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:58 +0000 Subject: [PATCH 05/12] Reopen aspect registration for PHPUnit proxy preparation Explicitly reopen aspect registration before the PHPUnit extension installs temporary Guzzle proxy rules. ParaTest can load the extension after booting and terminating its process-setup applications, which leaves registration sealed. Use the existing finally cleanup to restore saved rules on success and failure. Avoid introducing seal-state snapshots or a getter for an application lifecycle that has already ended. Add regression coverage for entering proxy preparation with a sealed registry and for restoring saved rules when generation rejects an already-loaded target. Validation: the extension test file and focused parallel testing suites passed; formatting and full static analysis passed. --- .../src/PHPUnit/AfterEachTestExtension.php | 3 ++ .../PHPUnit/AfterEachTestExtensionTest.php | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/testing/src/PHPUnit/AfterEachTestExtension.php b/src/testing/src/PHPUnit/AfterEachTestExtension.php index 66a1cb50d2..b69a9a5dbf 100644 --- a/src/testing/src/PHPUnit/AfterEachTestExtension.php +++ b/src/testing/src/PHPUnit/AfterEachTestExtension.php @@ -52,6 +52,9 @@ protected function generateGuzzleProxies(): void $directory = ParallelTesting::tempDir('guzzle-proxies'); try { + // ParaTest boots and terminates process-setup applications before loading extensions. + AspectCollector::unseal(); + foreach ([ PromiseConstructionAspect::class, PromiseOperationAspect::class, diff --git a/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php b/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php index bf974ec4c6..f91dc42fe8 100644 --- a/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php @@ -8,8 +8,10 @@ use GuzzleHttp\Promise\Promise; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\ProxyMethod; +use Hypervel\Di\Exceptions\InvalidDefinitionException; use Hypervel\Filesystem\Filesystem; use Hypervel\Testing\ParallelTesting; +use Hypervel\Testing\PHPUnit\AfterEachTestExtension; use Hypervel\Tests\TestCase; use ReflectionMethod; use Symfony\Component\Process\Process; @@ -85,6 +87,34 @@ public function testGuzzleProxiesArePreparedWithoutActivatingInstrumentation(): $this->assertCount(1, (new ReflectionMethod(Client::class, 'transfer'))->getAttributes(ProxyMethod::class)); } + public function testProxyPreparationAcceptsASealedRegistryAndRestoresItsRules(): void + { + AspectCollector::setAround('SavedAspect', ['Missing\Target']); + $rules = AspectCollector::getRules(); + AspectCollector::seal(); + + (new ReflectionMethod(AfterEachTestExtension::class, 'generateGuzzleProxies')) + ->invoke(new AfterEachTestExtension); + + $this->assertSame($rules, AspectCollector::getRules()); + } + + public function testFailedProxyPreparationRestoresTheOriginalRules(): void + { + AspectCollector::setAround('SavedAspect', [self::class . '::testFailedProxyPreparationRestoresTheOriginalRules']); + $rules = AspectCollector::getRules(); + AspectCollector::seal(); + + try { + (new ReflectionMethod(AfterEachTestExtension::class, 'generateGuzzleProxies')) + ->invoke(new AfterEachTestExtension); + $this->fail('An already-loaded target must fail proxy generation.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString(self::class, $exception->getMessage()); + $this->assertSame($rules, AspectCollector::getRules()); + } + } + public function testOutsideGuzzleCallbacksAreDiscardedBetweenTestsAndAtShutdown(): void { $process = $this->runFixture('GuzzleCleanupFixture.php', []); From 5750bb5134af4078047af2ee1264ee4aa78c0e18 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:58 +0000 Subject: [PATCH 06/12] Restore application globals when Testbench refresh fails A failed replacement application can leave the test holding its previous app while container and facade globals still point to the flushed replacement. Subsequent facade calls and teardown then operate on the wrong application. Catch failures only around replacement creation. When a previous application exists, restore its container and facade application, discard resolved facade instances, and rethrow the original exception. Keep successful route setup outside this recovery path. Add a regression test whose replacement provider resolves Config before throwing a known exception. Assert the same exception is propagated and the test app, global container, app helper, facade application, and Config root all return to the previous application. Validation: the regression test, package-mode Testbench suite, formatting, and full static analysis passed. --- src/testbench/src/TestCase.php | 19 ++++++- tests/Testbench/RefreshApplicationTest.php | 65 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/Testbench/RefreshApplicationTest.php diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index dd0304387e..ed55da1066 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -5,14 +5,17 @@ namespace Hypervel\Testbench; use Closure; +use Hypervel\Container\Container; use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Application; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Foundation\Testing\DatabaseTransactions; use Hypervel\Foundation\Testing\DatabaseTruncation; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Foundation\Testing\TestCase as BaseTestCase; +use Hypervel\Support\Facades\Facade; use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; use Hypervel\Testbench\Attributes\WithMigration; use ReflectionMethod; @@ -189,7 +192,21 @@ protected function prepareDatabaseTruncationForMethod(array $uses): void */ protected function refreshApplication(): void { - $this->app = $this->createApplication(); + try { + $this->app = $this->createApplication(); + } catch (Throwable $exception) { + // Failed creation flushes the replacement, but the test still owns its previous app. + if ($this->app !== null) { + /** @var Application $application */ + $application = $this->app; + + Container::setInstance($application); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($application); + } + + throw $exception; + } // Setup routes after application is created (providers are booted). $this->setUpApplicationRoutes($this->app); diff --git a/tests/Testbench/RefreshApplicationTest.php b/tests/Testbench/RefreshApplicationTest.php new file mode 100644 index 0000000000..13552a7dec --- /dev/null +++ b/tests/Testbench/RefreshApplicationTest.php @@ -0,0 +1,65 @@ +bootstrapFailure === null) { + return []; + } + + $app->instance('test.refresh_failure', $this->bootstrapFailure); + + return [FailingRefreshApplicationProvider::class]; + } + + public function testFailedRefreshRestoresThePreviousApplicationGlobals(): void + { + $previousApplication = $this->app; + $previousConfig = Config::getFacadeRoot(); + $this->bootstrapFailure = new RuntimeException('Replacement application failed to boot.'); + + try { + $this->refreshApplication(); + $this->fail('The replacement application must fail to boot.'); + } catch (RuntimeException $exception) { + $this->assertSame($this->bootstrapFailure, $exception); + } + + $this->assertSame($previousApplication, $this->app); + $this->assertSame($previousApplication, Container::getInstance()); + $this->assertSame($previousApplication, Facade::getFacadeApplication()); + $this->assertSame($previousApplication, app()); + $this->assertSame($previousConfig, Config::getFacadeRoot()); + } +} + +class FailingRefreshApplicationProvider extends ServiceProvider +{ + /** + * Resolve a facade against the new application before failing its bootstrap. + */ + public function boot(): void + { + Config::get('app.name'); + + throw $this->app->make('test.refresh_failure'); + } +} From 2f90147cdc64852f233274db47a09c7fbe0ef7d8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:58 +0000 Subject: [PATCH 07/12] Close proxy registration automatically before provider boot Open aspect and class map registration at the start of provider registration, then seal both registries after successful application proxy generation. Close registration even when no aspects exist or no targets match. Applications and packages need no manual seal calls. Keep standalone generate() reusable without sealing. Update generation-only tests to call that API directly, and retain dedicated bootstrap coverage for empty, unmatched, and matching aspect rules. Exercise real provider boot rejection for both aspects and class maps. Cover reloadApplication() and refreshApplication() with reversed provider registration order, real promise proxy execution, retained overrides, and resealed registries. Validation: formatting, full static analysis, package-mode Testbench, dogfood, and the full framework suite passed. The final focused suite passed 308 tests with 854 assertions. One earlier full run stalled in a native worker; the isolated file completed and a fresh full retry passed unchanged. The hang was not reproduced and its cause remains unconfirmed. --- src/di/src/Bootstrap/GenerateProxies.php | 7 +- .../src/Bootstrap/RegisterProviders.php | 6 + tests/Di/Bootstrap/GenerateProxiesTest.php | 133 ++++++++++-------- .../Bootstrap/AspectRegistrationTest.php | 133 ++++++++++++++++++ .../Bootstrap/LateAspectRegistrationTest.php | 61 ++++++++ 5 files changed, 282 insertions(+), 58 deletions(-) create mode 100644 tests/Foundation/Bootstrap/AspectRegistrationTest.php create mode 100644 tests/Foundation/Bootstrap/LateAspectRegistrationTest.php diff --git a/src/di/src/Bootstrap/GenerateProxies.php b/src/di/src/Bootstrap/GenerateProxies.php index db5b84fd38..68e5b6fd47 100644 --- a/src/di/src/Bootstrap/GenerateProxies.php +++ b/src/di/src/Bootstrap/GenerateProxies.php @@ -23,8 +23,8 @@ * * Runs after all service providers have been registered (so all * aspects() calls have executed) and before boot() (so no targeted - * classes have been instantiated yet). No-ops when no aspects are - * registered. + * classes have been instantiated yet). Closes aspect and class map registration + * even when no proxies need to be generated. */ class GenerateProxies { @@ -47,6 +47,9 @@ public function bootstrap(ApplicationContract $app): void if (AspectCollector::hasAspects()) { $this->generate($app->bootstrapPath('cache/aop')); } + + AspectCollector::seal(); + ClassMapManager::seal(); } /** diff --git a/src/foundation/src/Bootstrap/RegisterProviders.php b/src/foundation/src/Bootstrap/RegisterProviders.php index 0721584920..7d9dfbce34 100644 --- a/src/foundation/src/Bootstrap/RegisterProviders.php +++ b/src/foundation/src/Bootstrap/RegisterProviders.php @@ -5,6 +5,8 @@ namespace Hypervel\Foundation\Bootstrap; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Di\ClassMap\ClassMapManager; class RegisterProviders { @@ -25,6 +27,10 @@ class RegisterProviders */ public function bootstrap(ApplicationContract $app): void { + // Each application opens a registration window; GenerateProxies closes it before provider boot. + AspectCollector::unseal(); + ClassMapManager::unseal(); + if (! $app->bound('config_loaded_from_cache') || $app->make('config_loaded_from_cache') === false) { $this->mergeAdditionalProviders($app); diff --git a/tests/Di/Bootstrap/GenerateProxiesTest.php b/tests/Di/Bootstrap/GenerateProxiesTest.php index 7accfa81a9..717b62408e 100644 --- a/tests/Di/Bootstrap/GenerateProxiesTest.php +++ b/tests/Di/Bootstrap/GenerateProxiesTest.php @@ -8,7 +8,6 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\AspectCollector; -use Hypervel\Di\Aop\AspectManager; use Hypervel\Di\Aop\AstVisitorRegistry; use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Di\Aop\ProxyCallVisitor; @@ -70,21 +69,57 @@ protected function tearDown(): void parent::tearDown(); } - public function testNoOpsWhenNoAspectsRegistered(): void + #[DataProvider('bootstrapAspectCases')] + public function testBootstrapClosesRegistration(string $aspects): void { - $app = m::mock(ApplicationContract::class); - $app->shouldNotReceive('storagePath'); + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir) use ($aspects): void { + $app = m::mock(ApplicationContract::class); - (new GenerateProxies)->bootstrap($app); + if ($aspects === 'none') { + $app->shouldNotReceive('bootstrapPath'); + } else { + AspectCollector::setAround(OverrideSourceAspect::class, [ + $aspects === 'matching' ? $className . '::value' : 'Missing\Target', + ]); + $app->shouldReceive('bootstrapPath')->with('cache/aop')->andReturn($proxyDir); + } + + (new IsolatedProxyGenerator)->bootstrap($app); + + $this->assertSame($aspects !== 'none', AstVisitorRegistry::exists(ProxyCallVisitor::class)); + if ($aspects === 'matching') { + $this->assertSame('intercepted:original-source', (new $className)->value()); + } + + try { + AspectCollector::setAround('LateAspect', [$className . '::value']); + $this->fail('Application bootstrap must close aspect registration.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString('register()', $exception->getMessage()); + } - $this->assertFalse(AstVisitorRegistry::exists(ProxyCallVisitor::class)); + try { + ClassMapManager::add([$className => $overrideFile]); + $this->fail('Application bootstrap must close class map registration.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('after proxy generation', $exception->getMessage()); + } + }); + } + + /** + * Provide application bootstraps with and without proxy targets. + */ + public static function bootstrapAspectCases(): array + { + return [['none'], ['unmatched'], ['matching']]; } public function testRegistersProxyCallVisitorWhenAspectsExist(): void { AspectCollector::setAround('SomeAspect', ['SomeNonExistentClass']); - $this->bootstrapProxies($this->tempDirectory . '/aop'); + (new IsolatedProxyGenerator)->generate($this->tempDirectory . '/aop'); $this->assertTrue(AstVisitorRegistry::exists(ProxyCallVisitor::class)); } @@ -149,7 +184,7 @@ public function testDoesNotRegisterProxyCallVisitorTwice(): void AspectCollector::setAround('SomeAspect', ['SomeNonExistentClass']); AstVisitorRegistry::insert(ProxyCallVisitor::class); - $this->bootstrapProxies($this->tempDirectory . '/aop'); + (new IsolatedProxyGenerator)->generate($this->tempDirectory . '/aop'); $queue = clone AstVisitorRegistry::getQueue(); $count = 0; @@ -168,13 +203,13 @@ public function testRegeneratesDeletedProxyFilesFromTheApplicationSourceMap(): v $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertFileExists($proxyFile); $this->filesystem->deleteDirectory($proxyDir); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertFileExists($proxyFile); $this->assertStringContainsString('original-source', $this->filesystem->get($proxyFile)); @@ -188,7 +223,7 @@ public function testPublishesProxiesWithoutChangingTheApplicationClassMap(): voi $originalMap = $this->loader->getClassMap(); AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame($originalMap, $this->loader->getClassMap()); $this->assertSame([$className], array_keys(IsolatedProxyGenerator::getProxyMap())); @@ -242,7 +277,7 @@ public function testSelectsMixedExactAndWildcardRulesInOrderWithoutDuplicates(): $prefix . '\Missing*', ]); - $this->bootstrapProxies($this->tempDirectory . '/aop'); + (new IsolatedProxyGenerator)->generate($this->tempDirectory . '/aop'); $this->assertSame( [$classes[1], $classes[0]], @@ -264,22 +299,22 @@ public function testRejectsTargetsLoadedBeforeProxyGeneration(): void $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage($className); $this->expectExceptionMessage('register()'); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); }); } - public function testLoadedProxiesAllowRepeatedBootsSubsetRulesAndUnrelatedAspects(): void + public function testLoadedProxiesAllowRegenerationSubsetRulesAndUnrelatedAspects(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertTrue(class_exists($className)); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); AspectCollector::forgetAspect('TestAspect'); AspectCollector::setAround('TestAspect', [$className . '::val*']); AspectCollector::setAround('UnrelatedAspect', ['Missing\UnrelatedTarget']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertNotSame($sourceFile, (new ReflectionMethod($className, 'value'))->getFileName()); }); @@ -289,13 +324,13 @@ public function testLoadedProxyRejectsAdditionalMethodRequirements(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertTrue(class_exists($className)); AspectCollector::setAround('TestAspect', [$className]); $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage($className . '::other'); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); }); } @@ -304,7 +339,7 @@ public function testRegeneratesPsr4ProxiesWithoutChangingSourceResolution(): voi $this->withPsr4ProxyFixture(function (string $className, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertFileExists($proxyFile); @@ -314,7 +349,7 @@ public function testRegeneratesPsr4ProxiesWithoutChangingSourceResolution(): voi $this->assertNotSame($source, $proxyFile); $this->filesystem->deleteDirectory($proxyDir); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertFileExists($proxyFile); $this->assertStringContainsString('psr4-source', $this->filesystem->get($proxyFile)); @@ -326,12 +361,12 @@ public function testRegeneratesProxyWhenSourceChangesWithoutAnMtimeChange(): voi $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $sourceMtime = filemtime($sourceFile); $this->writeProxySource($sourceFile, $className, 'same-mtime-source'); touch($sourceFile, $sourceMtime); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertStringContainsString('same-mtime-source', $this->filesystem->get($proxyFile)); @@ -343,12 +378,12 @@ public function testRegeneratesProxyWhenSourcePathChanges(): void $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->writeProxySource($overrideFile, $className, 'override-source'); touch($overrideFile, (int) filemtime($sourceFile) - 100); $this->loader->addClassMap([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertStringContainsString('override-source', $this->filesystem->get($proxyFile)); @@ -360,12 +395,12 @@ public function testReplacementProxyDoesNotOverwriteTheOrdinaryProxy(): void $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $ordinaryProxy = IsolatedProxyGenerator::getProxyMap()[$className]; $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); $this->assertStringContainsString('original-source', $this->filesystem->get($ordinaryProxy)); @@ -386,16 +421,15 @@ public function testUnloadedReplacementProxyDoesNotSurviveReset(bool $ordinaryPr AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); if ($ordinaryProxyExists) { - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); } $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); ClassMapManager::flushState(); AspectCollector::flushState(); - AspectManager::flushState(); $this->assertSame('original-source', (new $className)->value()); }); @@ -415,12 +449,12 @@ public function testLoadedOverrideProxyAcceptsOnlyItsOriginalSourceAfterReset(): $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); ClassMapManager::flushState(); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); @@ -431,17 +465,17 @@ public function testLoadedOverrideProxyAcceptsOnlyItsOriginalSourceAfterReset(): }); } - public function testProxyKeepsPriorityOverAnOverrideRegisteredOnTheNextBoot(): void + public function testProxyKeepsPriorityOverAnOverrideRegisteredAfterReset(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); ClassMapManager::flushState(); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); }); @@ -452,12 +486,12 @@ public function testRegeneratesProxyWhenAspectRulesChange(): void $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('FirstAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $first = $this->filesystem->get($proxyFile); AspectCollector::setAround('SecondAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertNotSame($first, $this->filesystem->get($proxyFile)); }); @@ -470,14 +504,14 @@ public function testRegeneratesProxyWhenVisitorOrderChanges(): void AstVisitorRegistry::insert(FingerprintVisitorOne::class, 20); AstVisitorRegistry::insert(FingerprintVisitorTwo::class, 10); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $first = $this->filesystem->get($proxyFile); AstVisitorRegistry::flushState(); AstVisitorRegistry::insert(FingerprintVisitorOne::class, 10); AstVisitorRegistry::insert(FingerprintVisitorTwo::class, 20); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertNotSame($first, $this->filesystem->get($proxyFile)); }); @@ -499,7 +533,7 @@ public function testUsesCollisionFreeEncodedProxyFilenames(): void ]); AspectCollector::setAround('TestAspect', [$firstClass, $secondClass]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $firstProxy = IsolatedProxyGenerator::getProxyMap()[$firstClass]; $secondProxy = IsolatedProxyGenerator::getProxyMap()[$secondClass]; @@ -514,7 +548,7 @@ public function testReusesAProxyFromItsFingerprintHeaderWithoutParsingItsBody(): { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $lines = file($proxyFile); @@ -522,7 +556,7 @@ public function testReusesAProxyFromItsFingerprintHeaderWithoutParsingItsBody(): $cachedBody = $lines[0] . $lines[1] . "cached-body-is-not-parsed\n"; $this->filesystem->put($proxyFile, $cachedBody); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame($cachedBody, $this->filesystem->get($proxyFile)); }); @@ -553,7 +587,7 @@ class ExtraClass {} $exception = null; try { - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); } catch (Throwable $throwable) { $exception = $throwable; } @@ -614,19 +648,6 @@ private function withPsr4ProxyFixture(callable $callback): void $callback($className, $proxyDir); } - /** - * Bootstrap proxy generation against the given proxy directory. - */ - private function bootstrapProxies(string $proxyDir): void - { - $app = m::mock(ApplicationContract::class); - $app->shouldReceive('bootstrapPath') - ->with('cache/aop') - ->andReturn($proxyDir); - - (new IsolatedProxyGenerator)->bootstrap($app); - } - /** * Write a source file used for proxy generation. */ diff --git a/tests/Foundation/Bootstrap/AspectRegistrationTest.php b/tests/Foundation/Bootstrap/AspectRegistrationTest.php new file mode 100644 index 0000000000..c52b95e123 --- /dev/null +++ b/tests/Foundation/Bootstrap/AspectRegistrationTest.php @@ -0,0 +1,133 @@ +providers; + } + + #[DataProvider('applicationRestarts')] + public function testApplicationRestartsReopenAndResealRegistration(string $restart): void + { + $firstApplication = $this->app; + $this->assertSame('outer(inner(value))', $this->waitForValue()); + + $this->providers = array_reverse($this->providers); + $this->{$restart}(); + + $this->assertNotSame($firstApplication, $this->app); + $this->assertSame('outer(inner(value))', $this->waitForValue()); + $this->assertSame([ + RegistrationReplacement::class => __FILE__, + ], ClassMapManager::getEntries()); + + try { + AspectCollector::register(OuterRegistrationAspect::class); + $this->fail('A fresh application must close aspect registration again.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString('register()', $exception->getMessage()); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageIsOrContains('after proxy generation'); + ClassMapManager::add([RegistrationReplacement::class => __FILE__]); + } + + /** + * Provide the supported ways to recreate the test application. + */ + public static function applicationRestarts(): array + { + return [['reloadApplication'], ['refreshApplication']]; + } + + /** + * Exercise the real generated promise proxy. + */ + private function waitForValue(): string + { + $promise = new Promise; + $promise->resolve('value'); + + return $promise->wait(); + } +} + +class OuterRegistrationProvider extends ServiceProvider +{ + /** + * Register the outer aspect and a repeatable class map entry. + */ + public function register(): void + { + $this->aspects(OuterRegistrationAspect::class); + $this->classMap([RegistrationReplacement::class => __FILE__]); + } +} + +class InnerRegistrationProvider extends ServiceProvider +{ + /** + * Register the inner aspect. + */ + public function register(): void + { + $this->aspects(InnerRegistrationAspect::class); + } +} + +class OuterRegistrationAspect extends AbstractAspect +{ + public array $classes = [Promise::class . '::wait']; + + public ?int $priority = 20; + + /** + * Wrap the promise result with the outer marker. + */ + public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed + { + return 'outer(' . $proceedingJoinPoint->process() . ')'; + } +} + +class InnerRegistrationAspect extends AbstractAspect +{ + public array $classes = [Promise::class . '::wait']; + + public ?int $priority = 10; + + /** + * Wrap the promise result with the inner marker. + */ + public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed + { + return 'inner(' . $proceedingJoinPoint->process() . ')'; + } +} + +class RegistrationReplacement +{ +} diff --git a/tests/Foundation/Bootstrap/LateAspectRegistrationTest.php b/tests/Foundation/Bootstrap/LateAspectRegistrationTest.php new file mode 100644 index 0000000000..81403ddc92 --- /dev/null +++ b/tests/Foundation/Bootstrap/LateAspectRegistrationTest.php @@ -0,0 +1,61 @@ +expectException(InvalidDefinitionException::class); + $this->expectExceptionMessageIsOrContains('Register aspects in a service provider\'s register()'); + + Application::create( + (string) default_skeleton_path(), + options: ['extra' => ['providers' => [LateAspectRegistrationProvider::class]]], + ); + } + + public function testProviderBootCannotRegisterClassMapOverrides(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageIsOrContains('Register overrides in a service provider\'s register()'); + + Application::create( + (string) default_skeleton_path(), + options: ['extra' => ['providers' => [LateClassMapRegistrationProvider::class]]], + ); + } +} + +class LateAspectRegistrationProvider extends ServiceProvider +{ + /** + * Attempt to register an aspect during provider boot. + */ + public function boot(): void + { + $this->aspects(TestAspect::class); + } +} + +class LateClassMapRegistrationProvider extends ServiceProvider +{ + /** + * Attempt to register a class map during provider boot. + */ + public function boot(): void + { + $this->classMap([self::class => __FILE__]); + } +} From 2ccbf8d92281b8e6e8d8376866d3f0558c45af87 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:59 +0000 Subject: [PATCH 08/12] Document automatic aspect sealing and focused test registration Explain that aspect registration closes after application proxy generation and that registrations in boot() or later throw an exception. Clarify that registration closes even when no proxy files are needed. Update the focused testing example to reopen registration explicitly. Explain that reopening retains application aspects, cannot rewrite loaded classes, and relies on normal test cleanup to remove test registrations. Keep the existing guidance on early class loading, proxy fingerprints, and calling already-proxied objects. Documentation matches the verified bootstrap and focused-testing behavior. --- src/docs/aop.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/docs/aop.md b/src/docs/aop.md index da011eaeca..83492455e2 100644 --- a/src/docs/aop.md +++ b/src/docs/aop.md @@ -217,7 +217,7 @@ $this->aspects(ProfileReports::class, TraceHttpRequests::class); ``` > [!WARNING] -> Aspects must be registered during `register`, before the target classes are loaded. Runtime registration cannot retroactively apply aspects to classes that have already been loaded or proxied. +> Aspects must be registered during `register`, before the target classes are loaded. Registration closes after the application's proxy generation step. Registering an aspect during `boot` or later throws an exception. ## Proxy Generation @@ -226,7 +226,7 @@ Hypervel generates AOP proxy classes automatically during application bootstrap. Target classes must not be loaded before proxy generation. Bind factories in your providers' `register` methods and create their instances during `boot` or later. Hypervel reports an error if a target was loaded too early, or if an already-loaded proxy cannot apply the current method rules. Restart workers after changing aspect rules. -If no aspects have been registered, proxy generation does nothing. Existing proxies are reused only while their content fingerprint still matches the source path and contents, aspect rules, registered AST visitors, generator implementation, PHP parser, and PHP version. Changes to any of those inputs regenerate the affected proxy during the next application bootstrap. +If no aspects have been registered, no proxy files are generated, but registration still closes. Existing proxies are reused only while their content fingerprint still matches the source path and contents, aspect rules, registered AST visitors, generator implementation, PHP parser, and PHP version. Changes to any of those inputs regenerate the affected proxy during the next application bootstrap. Each targeted source file must declare exactly one named class, interface, trait, or enum. Nested anonymous classes are supported, but multiple named class-like declarations should be split into separate files before applying an aspect. Methods that return by reference cannot be intercepted because the aspect pipeline returns values rather than reference identities. @@ -268,6 +268,7 @@ class ProfileReportsTest extends TestCase public function test_it_runs_the_aspect(): void { + AspectCollector::unseal(); AspectCollector::setAround(ProfileReports::class, [ ReportService::class . '::generate', ]); @@ -283,4 +284,6 @@ class ProfileReportsTest extends TestCase } ``` +The `unseal` method reopens registration for this test while keeping the application's existing aspects. It does not change classes that have already been loaded, so only use it in tests. Aspects registered by a test are removed before the next test runs. + The `callWithAspects` method expects an object that has not already been proxied. If the object is already proxied, call the method normally instead. From cc4b262705cec6860ecf0489be31c538b49e559d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:59 +0000 Subject: [PATCH 09/12] Clarify the provider registration deadline for proxy APIs State the shared registration deadline for provider aspects and class map overrides: call them from register(), before targets load and before proxy generation closes registration. Explain the exception raised by calls during boot() or later alongside the existing advanced-provider examples. No manual sealing API is required in those examples because application bootstrap owns the lifecycle. Validation: checked the guidance against both kernel bootstrap sequences and the provider boot regression tests. --- src/docs/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/providers.md b/src/docs/providers.md index 114df4099e..46fc750a77 100644 --- a/src/docs/providers.md +++ b/src/docs/providers.md @@ -244,7 +244,7 @@ When this method returns `false`, the provider's `register` and `boot` methods w ### Advanced Provider APIs -Package service providers may also register AOP aspects and class map overrides. These methods should be called within the `register` method before the target classes are loaded: +Package service providers may also register AOP aspects and class map overrides. Call these methods within `register`, before the target classes are loaded. Registration closes after proxy generation, so calls during `boot` or later throw an exception: ```php use App\Aspects\TraceRequests; From 520dad90560c15b1cde3f9422e9ca66091593168 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:59 +0000 Subject: [PATCH 10/12] Document sealed class map registration for packages Explain that class map override registration closes after proxy generation and rejects calls during boot() or later. Retain the immediate-autoload effect, unloaded-target requirement, same-source repeat registration support, and different-source rejection for loaded targets. Clarify that repeated overrides belong inside the registration window, including repeated Testbench application bootstraps. Validation: checked the wording against class map rejection tests and repeated-application bootstrap coverage. --- src/docs/packages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/packages.md b/src/docs/packages.md index e5138236ab..407e885e82 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -319,7 +319,7 @@ public function register(): void The replacement file should define the original class name. In the example above, Composer is still being asked to load `VendorPackage\Client`; Hypervel simply tells Composer to load that class from your replacement file instead of the dependency's original file. The replacement should remain compatible with the original class's public API, since other code will still interact with it as `VendorPackage\Client`. -Class map overrides take effect immediately and must be registered before the target class, interface, or trait has been loaded. Register them in the service provider's `register` method before resolving services or referencing classes that may load the target. Repeating an override for the same source file is safe, including when Testbench boots another application. Attempting to replace a loaded target with a different source file throws an exception. +Class map overrides take effect immediately and must be registered before the target class, interface, or trait has been loaded. Register them in the service provider's `register` method before resolving services or referencing classes that may load the target. Registration closes after proxy generation; registering an override during `boot` or later throws an exception. Repeating an override for the same source file during registration is safe, including when Testbench boots another application. Attempting to replace a loaded target with a different source file throws an exception. > [!WARNING] > Class map overrides affect the worker's autoloader for the lifetime of the process. They should only be used for boot-time package integration, not request-specific behavior. From f5ba53ee8acc86087814c0e285781afea3f5ffe1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:59 +0000 Subject: [PATCH 11/12] Record the completed proxy registration lifecycle design Record the final ownership of registration windows, the small seal and unseal APIs, mutation-driven chain invalidation, and explicit focused-testing support. Include the PHPUnit preparation lifecycle and failed Testbench refresh recovery, with the reasons for keeping standalone generation reusable and avoiding extra state-management machinery or dispatch overhead. Mark implementation and verification complete and remove the stale uncommitted status. Keep this design record concise and separate from user-facing framework documentation. --- .../plans/2026-10-14-aop-registration-seal.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/plans/2026-10-14-aop-registration-seal.md diff --git a/docs/plans/2026-10-14-aop-registration-seal.md b/docs/plans/2026-10-14-aop-registration-seal.md new file mode 100644 index 0000000000..8a37e1f09e --- /dev/null +++ b/docs/plans/2026-10-14-aop-registration-seal.md @@ -0,0 +1,20 @@ +# Close registration before provider boot + +## Design + +Aspect rules and class-map replacements determine generated proxies. Registration after generation can silently miss a target, leave a method unintercepted, or bypass an existing proxy. Enforce the existing provider `register()` contract without adding work to method dispatch. + +- Give `AspectCollector` and `ClassMapManager` a protected static boolean seal, with public `seal()` and `unseal()` methods. Document these worker-wide mutators as boot/test operations. Do not introduce shared traits, testing detection, snapshots, or additional state machinery. +- Check the seal inline in `AspectCollector::setAround()` and `ClassMapManager::add()` before mutation. `register()` already delegates to `setAround()`; no duplicate guard is needed. Throw `InvalidDefinitionException` for aspects and the existing `RuntimeException` for replacements, directing callers to provider `register()`. +- Reopen both registries at the start of `RegisterProviders::bootstrap()` so reloads and refreshes within one test can register providers again. Preserve existing rules/replacements when reopening. Testbench inherits this bootstrapper. +- Seal both registries after successful `GenerateProxies::bootstrap()`, including when there are no aspects or no matching targets. Keep standalone `generate()` reusable and non-sealing. Do not recover failed partial bootstraps. +- Add `AspectManager::clear()` to discard compiled chains. Call it whenever aspect rules are registered, forgotten, or reset; `AspectManager::flushState()` delegates to it. Rule changes own invalidation, so adding/removing aspects between focused test calls cannot retain stale dispatch. `unseal()` only changes the flag. Dispatch and chain execution gain no checks or counters. +- `flushState()` restores each registry to its initial open state. Existing framework cleanup still owns static cleanup. The PHPUnit extension already resets aspect rules before restoring its saved registrations. +- The PHPUnit extension explicitly reopens aspect registration before preparing temporary proxy metadata. ParaTest's parent process has already terminated its setup applications before loading extensions; retaining their closed registration window serves no caller. Keep the existing `finally` rule restoration and open end state. Do not add a seal getter or state-restoration machinery. +- Preserve focused `callWithAspects()` tests after application bootstrap through explicit `AspectCollector::unseal()`. No automatic PHPUnit or Testbench bypass. Normal provider `boot()` registration must fail in tests too. +- When Testbench `refreshApplication()` fails while creating a replacement app, restore the previous test application's container and facade globals, clear resolved facade instances, and rethrow the original exception. Its failed-app cleanup already flushes the replacement; leaving globals pointed there breaks subsequent facade calls and teardown. Limit recovery to failed creation with an existing test application; keep successful route setup outside the catch. +- Update `aop.md`, the advanced-provider section of `providers.md`, and class-map guidance in `packages.md`. Explain the closed boundary and explicit focused-testing opt-in concisely. Remove stale no-op wording and touched comments. This fixes Hypervel-only contracts and needs no Laravel porting-guide entry. + +## Status + +Implementation, verification, and peer review are complete. From 1ada627b2b253623443fdb0e7825d513cb2f96eb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:13:58 +0000 Subject: [PATCH 12/12] Clarify failed Testbench refresh cleanup Explain that restoring the previous container and facades allows framework teardown to run after replacement application creation fails. The original exception still propagates; this recovery does not promise that the previous application can resume normal use. Rename the regression test to describe the teardown guarantee while retaining its existing assertions. No runtime behavior, registration APIs, or state restoration logic changes. Validation: the renamed RefreshApplicationTest passes and git diff --check is clean. --- src/testbench/src/TestCase.php | 3 ++- tests/Testbench/RefreshApplicationTest.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index ed55da1066..83c91d9154 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -195,7 +195,8 @@ protected function refreshApplication(): void try { $this->app = $this->createApplication(); } catch (Throwable $exception) { - // Failed creation flushes the replacement, but the test still owns its previous app. + // Failed creation flushes the replacement. Restore the previous container + // and facades so framework teardown can run. if ($this->app !== null) { /** @var Application $application */ $application = $this->app; diff --git a/tests/Testbench/RefreshApplicationTest.php b/tests/Testbench/RefreshApplicationTest.php index 13552a7dec..19a7076fdc 100644 --- a/tests/Testbench/RefreshApplicationTest.php +++ b/tests/Testbench/RefreshApplicationTest.php @@ -30,7 +30,7 @@ protected function getPackageProviders(ApplicationContract $app): array return [FailingRefreshApplicationProvider::class]; } - public function testFailedRefreshRestoresThePreviousApplicationGlobals(): void + public function testFailedRefreshRestoresContainerAndFacadesForTeardown(): void { $previousApplication = $this->app; $previousConfig = Config::getFacadeRoot();