From 7452c080ebe73bef8201042644c73b2664367aee Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 22:55:44 +0100 Subject: [PATCH 001/134] fix(auth): rename PHPDoc templates to T-prefix convention --- packages/auth/src/AccessControl/AccessControl.php | 15 +++++++-------- .../AccessControl/PolicyBasedAccessControl.php | 6 +++--- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/auth/src/AccessControl/AccessControl.php b/packages/auth/src/AccessControl/AccessControl.php index effe129674..5d4f18fbb5 100644 --- a/packages/auth/src/AccessControl/AccessControl.php +++ b/packages/auth/src/AccessControl/AccessControl.php @@ -2,21 +2,21 @@ namespace Tempest\Auth\AccessControl; +use Tempest\Auth\Exceptions\AccessWasDenied; use UnitEnum; /** - * @template Subject of object - * @template Resource of object + * @template TSubject of object + * @template TResource of object */ interface AccessControl { /** * Checks if the action is granted for the given resource and subject. If not, an exception is thrown. * - * @template Resource of object * @param UnitEnum|string $action An arbitrary action to check access for, e.g. 'view', 'edit', etc. - * @param Resource|class-string $resource A model instance or class string of a model to check access for. - * @param null|Subject $subject An optional subject to check access against, e.g. a user or service account. + * @param TResource|class-string $resource A model instance or class string of a model to check access for. + * @param null|TSubject $subject An optional subject to check access against, e.g. a user or service account. * * @throws AccessWasDenied */ @@ -25,10 +25,9 @@ public function ensureGranted(UnitEnum|string $action, object|string $resource, /** * Checks if the action is granted for the given resource and subject. * - * @template Resource of object * @param UnitEnum|string $action An arbitrary action to check access for, e.g. 'view', 'edit', etc. - * @param Resource|class-string $resource A model instance or class string of a model to check access for. - * @param null|Subject $subject An optional subject to check access against, e.g. a user or service account. + * @param TResource|class-string $resource A model instance or class string of a model to check access for. + * @param null|TSubject $subject An optional subject to check access against, e.g. a user or service account. */ public function isGranted(UnitEnum|string $action, object|string $resource, ?object $subject = null): AccessDecision; } diff --git a/packages/auth/src/AccessControl/PolicyBasedAccessControl.php b/packages/auth/src/AccessControl/PolicyBasedAccessControl.php index 98b353f148..3c6b06327c 100644 --- a/packages/auth/src/AccessControl/PolicyBasedAccessControl.php +++ b/packages/auth/src/AccessControl/PolicyBasedAccessControl.php @@ -16,9 +16,9 @@ use UnitEnum; /** - * @template Subject of object - * @template Resource of object - * @implements AccessControl + * @template TSubject of object + * @template TResource of object + * @implements AccessControl */ final readonly class PolicyBasedAccessControl implements AccessControl { From b66c44aa3f4f70fab30e8f31a99fce94a85f7aab Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 22:55:59 +0100 Subject: [PATCH 002/134] refactor(auth): remove unused Database dependency --- .../AuthenticatableResolverInitializer.php | 5 +---- .../DatabaseAuthenticatableResolver.php | 16 ++++++++-------- .../Exceptions/ModelWasNotAuthenticatable.php | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/auth/src/Authentication/AuthenticatableResolverInitializer.php b/packages/auth/src/Authentication/AuthenticatableResolverInitializer.php index e2e5c01e36..24b7b8072e 100644 --- a/packages/auth/src/Authentication/AuthenticatableResolverInitializer.php +++ b/packages/auth/src/Authentication/AuthenticatableResolverInitializer.php @@ -5,15 +5,12 @@ use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; -use Tempest\Database\Database; final class AuthenticatableResolverInitializer implements Initializer { #[Singleton] public function initialize(Container $container): AuthenticatableResolver { - return new DatabaseAuthenticatableResolver( - database: $container->get(Database::class), - ); + return new DatabaseAuthenticatableResolver(); } } diff --git a/packages/auth/src/Authentication/DatabaseAuthenticatableResolver.php b/packages/auth/src/Authentication/DatabaseAuthenticatableResolver.php index c8e99b67c0..43cdd356c3 100644 --- a/packages/auth/src/Authentication/DatabaseAuthenticatableResolver.php +++ b/packages/auth/src/Authentication/DatabaseAuthenticatableResolver.php @@ -4,22 +4,15 @@ use Tempest\Auth\Exceptions\AuthenticatableModelWasInvalid; use Tempest\Auth\Exceptions\ModelWasNotAuthenticatable; -use Tempest\Database\Database; use function Tempest\Database\inspect; use function Tempest\Database\query; final readonly class DatabaseAuthenticatableResolver implements AuthenticatableResolver { - public function __construct( - private Database $database, - ) {} - public function resolve(int|string $id, string $class): ?Authenticatable { - if (! is_a($class, Authenticatable::class, allow_string: true)) { - throw new ModelWasNotAuthenticatable($class); - } + $this->ensureClassIsAuthenticatable($class); return query($class)->findById($id); } @@ -40,4 +33,11 @@ public function resolveId(Authenticatable $authenticatable): int|string return $id; } + + private function ensureClassIsAuthenticatable(string $class): void + { + if (! is_a($class, Authenticatable::class, allow_string: true)) { + throw new ModelWasNotAuthenticatable($class); + } + } } diff --git a/packages/auth/src/Exceptions/ModelWasNotAuthenticatable.php b/packages/auth/src/Exceptions/ModelWasNotAuthenticatable.php index 8df9835649..fc1a0292d1 100644 --- a/packages/auth/src/Exceptions/ModelWasNotAuthenticatable.php +++ b/packages/auth/src/Exceptions/ModelWasNotAuthenticatable.php @@ -8,7 +8,7 @@ final class ModelWasNotAuthenticatable extends Exception implements AuthenticationException { public function __construct( - private readonly string $class, + string $class, ) { parent::__construct( sprintf('`%s` must be an instance of `%s`', $class, Authenticatable::class), From dad865e3be114bcf9364b8a16f3393539de56936 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 22:56:07 +0100 Subject: [PATCH 003/134] fix(auth): remove unnecessary null-safe operators --- .../auth/src/AccessControl/PolicyBasedAccessControl.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/auth/src/AccessControl/PolicyBasedAccessControl.php b/packages/auth/src/AccessControl/PolicyBasedAccessControl.php index 3c6b06327c..3751de141e 100644 --- a/packages/auth/src/AccessControl/PolicyBasedAccessControl.php +++ b/packages/auth/src/AccessControl/PolicyBasedAccessControl.php @@ -119,11 +119,9 @@ private function ensureParameterAcceptsInput(?ParameterReflector $reflector, mix return; } - if (! ($type = $reflector?->getType())) { - return; - } + $type = $reflector->getType(); - if ($type?->accepts($input)) { + if ($type->accepts($input)) { return; } From 91787030b353a5e112a72d5d9dc11a08c8766965 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 22:56:17 +0100 Subject: [PATCH 004/134] fix(auth): correct OAuth scopes fallback and state handling --- .../auth/src/OAuth/GenericOAuthClient.php | 21 ++++++-- packages/auth/src/OAuth/OAuthClient.php | 2 - .../AuthenticationAndOAuthSafetyTest.php | 49 +++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 packages/auth/tests/AuthenticationAndOAuthSafetyTest.php diff --git a/packages/auth/src/OAuth/GenericOAuthClient.php b/packages/auth/src/OAuth/GenericOAuthClient.php index 9759711227..9b591f26ee 100644 --- a/packages/auth/src/OAuth/GenericOAuthClient.php +++ b/packages/auth/src/OAuth/GenericOAuthClient.php @@ -53,8 +53,12 @@ public function __construct( public function buildAuthorizationUrl(array $scopes = [], array $options = []): string { + if ($scopes === []) { + $scopes = $this->config->scopes; + } + return $this->provider->getAuthorizationUrl([ - 'scope' => $scopes ?? $this->config->scopes, + 'scope' => $scopes, 'redirect_uri' => $this->uri->createUri($this->config->redirectTo), ...$options, ]); @@ -71,16 +75,27 @@ public function createRedirect(array $scopes = [], array $options = []): Redirec public function getState(): ?string { - return $this->provider->getState(); + return $this->provider->getState() ?: null; } public function requestAccessToken(string $code): AccessToken { try { - return $this->provider->getAccessToken('authorization_code', [ + $token = $this->provider->getAccessToken('authorization_code', [ 'code' => $code, 'redirect_uri' => $this->uri->createUri($this->config->redirectTo), ]); + + if ($token instanceof AccessToken) { + return $token; + } + + return new AccessToken([ + ...$token->getValues(), + 'access_token' => $token->getToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires' => $token->getExpires(), + ]); } catch (IdentityProviderException $exception) { throw OAuthTokenCouldNotBeRetrieved::fromProviderException($exception); } diff --git a/packages/auth/src/OAuth/OAuthClient.php b/packages/auth/src/OAuth/OAuthClient.php index ee704abd43..63eb5566fe 100644 --- a/packages/auth/src/OAuth/OAuthClient.php +++ b/packages/auth/src/OAuth/OAuthClient.php @@ -43,8 +43,6 @@ public function fetchUser(AccessToken $token): OAuthUser; /** * Authenticates a user based on the given OAuth callback request. * - * @template T of Authenticatable - * * @param Closure(OAuthUser): T $map A callback that should return an authenticatable model from the given OAuthUser. Typically, the callback is also responsible for saving the user to the database. */ public function authenticate(Request $request, Closure $map): Authenticatable; diff --git a/packages/auth/tests/AuthenticationAndOAuthSafetyTest.php b/packages/auth/tests/AuthenticationAndOAuthSafetyTest.php new file mode 100644 index 0000000000..91beb330d3 --- /dev/null +++ b/packages/auth/tests/AuthenticationAndOAuthSafetyTest.php @@ -0,0 +1,49 @@ +expectException(ModelWasNotAuthenticatable::class); + + $resolve->invoke($resolver, 1, \stdClass::class); + } + + #[Test] + public function generic_oauth_client_state_is_null_before_generating_authorization_url(): void + { + $provider = new GenericProvider([ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://example.com/callback', + 'urlAuthorize' => 'https://provider.test/authorize', + 'urlAccessToken' => 'https://provider.test/token', + 'urlResourceOwnerDetails' => 'https://provider.test/user', + ]); + + $reflection = new ReflectionClass(GenericOAuthClient::class); + + /** @var GenericOAuthClient $client */ + $client = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('provider')->setValue($client, $provider); + + $this->assertNull($client->getState()); + } +} From 1088b9163254ac77a48cef3753108a7394989a33 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 22:56:28 +0100 Subject: [PATCH 005/134] fix(support): add TDefault generic to Arr first/last --- packages/support/src/Arr/ManipulatesArray.php | 10 ++++++++-- packages/support/src/Arr/functions.php | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/support/src/Arr/ManipulatesArray.php b/packages/support/src/Arr/ManipulatesArray.php index 633f4819ac..8c4887038f 100644 --- a/packages/support/src/Arr/ManipulatesArray.php +++ b/packages/support/src/Arr/ManipulatesArray.php @@ -347,9 +347,12 @@ public function equals(array|self $other): bool * Returns the first item in the instance that matches the given `$filter`. * If `$filter` is `null`, returns the first item. * + * @template TDefault + * * @param null|Closure(TValue $value, TKey $key): bool $filter + * @param TDefault $default * - * @return TValue + * @return TValue|TDefault */ public function first(?Closure $filter = null, mixed $default = null): mixed { @@ -360,9 +363,12 @@ public function first(?Closure $filter = null, mixed $default = null): mixed * Returns the last item in the instance that matches the given `$filter`. * If `$filter` is `null`, returns the last item. * + * @template TDefault + * * @param null|Closure(TValue $value, TKey $key): bool $filter + * @param TDefault $default * - * @return TValue + * @return TValue|TDefault */ public function last(?Closure $filter = null, mixed $default = null): mixed { diff --git a/packages/support/src/Arr/functions.php b/packages/support/src/Arr/functions.php index 4a919e2c24..f4464cd83f 100644 --- a/packages/support/src/Arr/functions.php +++ b/packages/support/src/Arr/functions.php @@ -544,11 +544,13 @@ function equals(iterable $array, iterable $other): bool * * @template TKey of array-key * @template TValue + * @template TDefault * * @param iterable $array * @param null|Closure(TValue $value, TKey $key): bool $filter + * @param TDefault $default * - * @return TValue + * @return TValue|TDefault */ function first(iterable $array, ?Closure $filter = null, mixed $default = null): mixed { @@ -593,11 +595,13 @@ function at(iterable $array, int $index, mixed $default = null): mixed * * @template TKey of array-key * @template TValue + * @template TDefault * * @param iterable $array * @param null|Closure(TValue $value, TKey $key): bool $filter + * @param TDefault $default * - * @return TValue + * @return TValue|TDefault */ function last(iterable $array, ?Closure $filter = null, mixed $default = null): mixed { From 17ec8d533cd820596959e9b3eaba4d95e558778e Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 23:41:41 +0100 Subject: [PATCH 006/134] fix(cache): correct PHPDoc annotations in Cache interface --- packages/cache/src/Cache.php | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/cache/src/Cache.php b/packages/cache/src/Cache.php index ee9ba65cb4..e46f189e96 100644 --- a/packages/cache/src/Cache.php +++ b/packages/cache/src/Cache.php @@ -37,11 +37,11 @@ public function put(Stringable|string $key, mixed $value, null|Duration|DateTime /** * Sets the specified keys to the specified values in the cache. Optionally, specify an expiration. * - * @template TKey of array-key + * @template TKey of Stringable|string * @template TValue * - * @param iterable $array - * @return array + * @param iterable $values + * @return array */ public function putMany(iterable $values, null|Duration|DateTimeInterface $expiration = null): array; @@ -53,11 +53,10 @@ public function get(Stringable|string $key): mixed; /** * Gets the values associated with the specified keys from the cache. If a key does not exist, null is returned for that key. * - * @template TKey of array-key - * @template TValue + * @template TKey of Stringable|string * - * @param iterable $array - * @return array + * @param iterable $key + * @return array */ public function getMany(iterable $key): array; @@ -79,7 +78,7 @@ public function decrement(Stringable|string $key, int $by = 1): int; /** * If the specified key already exists in the cache, the value is returned and the `$callback` is not executed. Otherwise, the result of the callback is stored, then returned. * - * @var null|Duration $stale Allow the value to be stale for the specified amount of time in addition to the time-to-live specified by `$expiration`. When a value is stale, it will still be returned as-is, but it will be refreshed in the background. + * @param null|Duration $stale Allow the value to be stale for the specified amount of time in addition to the time-to-live specified by `$expiration`. When a value is stale, it will still be returned as-is, but it will be refreshed in the background. */ public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed; From 1583b2f2e3dfe548633f601d8e7e22485e8f98a9 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 23:41:46 +0100 Subject: [PATCH 007/134] refactor(cache): remove unused dependency and redundant code --- packages/cache/src/Commands/CacheClearCommand.php | 3 +-- packages/cache/src/Commands/CacheStatusCommand.php | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cache/src/Commands/CacheClearCommand.php b/packages/cache/src/Commands/CacheClearCommand.php index f3ad69265e..d91c25a541 100644 --- a/packages/cache/src/Commands/CacheClearCommand.php +++ b/packages/cache/src/Commands/CacheClearCommand.php @@ -30,7 +30,6 @@ private const string DEFAULT_CACHE = 'default'; public function __construct( - private Cache $cache, private Container $container, ) {} @@ -59,7 +58,7 @@ private function clearInternalCaches(bool $all = false): void { $caches = [ConfigCache::class, ViewCache::class, IconCache::class, DiscoveryCache::class]; - if ($all === false && count($caches) > 1) { + if (! $all) { $caches = $this->ask( question: 'Which caches do you want to clear?', options: $caches, diff --git a/packages/cache/src/Commands/CacheStatusCommand.php b/packages/cache/src/Commands/CacheStatusCommand.php index 7df85538b0..766eda0d27 100644 --- a/packages/cache/src/Commands/CacheStatusCommand.php +++ b/packages/cache/src/Commands/CacheStatusCommand.php @@ -81,7 +81,6 @@ public function __invoke(bool $internal = true): void $this->console->header('User caches'); - /** @var Cache $cache */ foreach ($caches as $cache) { $this->console->keyValue( key: $this->getCacheName($cache), From 59a710fd990b7ccf0d21d04be6627cebd4bfb814 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 23:41:52 +0100 Subject: [PATCH 008/134] fix(cache): fix TestingCache property hook and add strict types --- packages/cache/src/Testing/TestingCache.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cache/src/Testing/TestingCache.php b/packages/cache/src/Testing/TestingCache.php index 516e2abe89..3313a8352f 100644 --- a/packages/cache/src/Testing/TestingCache.php +++ b/packages/cache/src/Testing/TestingCache.php @@ -1,5 +1,7 @@ $this->cache->enabled; - set => $this->cache->enabled = $value; + public bool $enabled = true { + set { + $this->cache->enabled = $value; + $this->enabled = $value; + } } private Cache $cache; From 20078aab91658c759d06d24d717a1b8d9066ba0b Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 23:41:57 +0100 Subject: [PATCH 009/134] fix(cache): improve UserCacheInsightsProvider PHPDoc types --- packages/cache/src/UserCacheInsightsProvider.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cache/src/UserCacheInsightsProvider.php b/packages/cache/src/UserCacheInsightsProvider.php index b97df59ae4..6cd359e84c 100644 --- a/packages/cache/src/UserCacheInsightsProvider.php +++ b/packages/cache/src/UserCacheInsightsProvider.php @@ -1,5 +1,7 @@ toArray(); } - /** @var Insight[] */ + /** + * @return array{0: ?Insight, 1: Insight} + */ private function getInsight(Cache $cache): array { $type = $cache instanceof GenericCache From f949a82f73112109a2d11618c6f7316d44e32af6 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 23:42:03 +0100 Subject: [PATCH 010/134] fix(support): add generic annotations to ManipulatesArray usage --- packages/support/src/Arr/ImmutableArray.php | 2 ++ packages/support/src/Arr/MutableArray.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/support/src/Arr/ImmutableArray.php b/packages/support/src/Arr/ImmutableArray.php index 6c11396497..84479a0408 100644 --- a/packages/support/src/Arr/ImmutableArray.php +++ b/packages/support/src/Arr/ImmutableArray.php @@ -13,6 +13,8 @@ final class ImmutableArray implements ArrayInterface { use IsIterable; + + /** @use ManipulatesArray */ use ManipulatesArray; /** diff --git a/packages/support/src/Arr/MutableArray.php b/packages/support/src/Arr/MutableArray.php index 2940468255..56f7b5ee50 100644 --- a/packages/support/src/Arr/MutableArray.php +++ b/packages/support/src/Arr/MutableArray.php @@ -13,6 +13,8 @@ final class MutableArray implements ArrayInterface { use IsIterable; + + /** @use ManipulatesArray */ use ManipulatesArray; /** From 2854134f0e376e1b9cea00ce71833d9052843e3b Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 14 Feb 2026 23:42:08 +0100 Subject: [PATCH 011/134] fix(support): improve type safety in Arr functions --- packages/support/src/Arr/functions.php | 38 +++++++++++++++++--------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/support/src/Arr/functions.php b/packages/support/src/Arr/functions.php index f4464cd83f..14ef8e5bb4 100644 --- a/packages/support/src/Arr/functions.php +++ b/packages/support/src/Arr/functions.php @@ -155,7 +155,7 @@ function remove_keys(iterable $array, string|int|array $keys): array * @param TValue|array $values The values to remove. * @return array */ -function remove_values(array $array, string|int|array $values): array +function remove_values(array $array, mixed $values): array { $array = to_array($array); @@ -193,7 +193,7 @@ function forget_keys(array &$array, string|int|array $keys): array * @param TValue|array $values The values to remove. * @return array */ -function forget_values(array &$array, string|int|array $values): array +function forget_values(array &$array, mixed $values): array { $values = is_array($values) ? $values : [$values]; @@ -670,18 +670,38 @@ function implode(iterable $array, string $glue): ImmutableString /** * Returns a copy of the given array with the keys of this array as values. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * + * @return list */ function keys(iterable $array): array { - return array_keys(to_array($array)); + /** @var list $result */ + $result = array_keys(to_array($array)); + + return $result; } /** * Returns a copy of the given array without its keys. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * + * @return list */ function values(iterable $array): array { - return array_values(to_array($array)); + /** @var list $result */ + $result = array_values(to_array($array)); + + return $result; } /** @@ -1143,7 +1163,7 @@ function sort(iterable $array, bool $desc = false, ?bool $preserveKeys = null, i * @template TValue * * @param iterable $array - * @param \Closure(TValue $a, TValue $b) $callback The function to use for comparing values. It should accept two parameters and return an integer less than, equal to, or greater than zero if the first argument is considered to be respectively less than, equal to, or greater than the second. + * @param \Closure(TValue, TValue): int $callback The function to use for comparing values. * @param bool|null $preserveKeys Preserves array keys if `true`; reindexes numerically if `false`. Defaults to `null`, which auto-detects preservation based on array type (associative or list). * @return array Key type depends on whether array keys are preserved or not. */ @@ -1267,10 +1287,6 @@ function range(int|float $start, int|float $end, int|float|null $step = null): a $result = []; - /** - * @var int|float $start - * @var int|float $step - */ for ($i = $start; $i <= $end; $i += $step) { $result[] = $i; } @@ -1289,10 +1305,6 @@ function range(int|float $start, int|float $end, int|float|null $step = null): a $result = []; - /** - * @var int|float $start - * @var int|float $step - */ for ($i = $start; $i >= $end; $i += $step) { $result[] = $i; } From 90f297712294b3b5d2cc6a05c70453b75c4d13a2 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 00:04:14 +0100 Subject: [PATCH 012/134] fix(support): improve generic type annotations in Arr functions --- packages/support/src/Arr/functions.php | 181 ++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 21 deletions(-) diff --git a/packages/support/src/Arr/functions.php b/packages/support/src/Arr/functions.php index 14ef8e5bb4..009078e123 100644 --- a/packages/support/src/Arr/functions.php +++ b/packages/support/src/Arr/functions.php @@ -102,9 +102,13 @@ function reduce(iterable $array, callable $callback, mixed $initial = null): mix * * @template TKey of array-key * @template TValue + * @template TDefault * * @param array $array - * @param array-key $key + * @param TKey $key + * @param TDefault $default + * + * @return TValue|TDefault */ function pull(array &$array, string|int $key, mixed $default = null): mixed { @@ -231,10 +235,14 @@ function is_associative(iterable $array): bool * @template TValue * * @param iterable $array - * @param int $number The number of random values to get. + * @param positive-int $number The number of random values to get. * @param bool $preserveKey Whether to include the keys of the original array. * - * @return array|mixed The random values, or a single value if `$number` is 1. + * @return ( + * $number is 1 + * ? TValue + * : ($preserveKey is true ? ImmutableArray : ImmutableArray) + * ) The random values, or a single value if `$number` is 1. */ function random(iterable $array, int $number = 1, bool $preserveKey = false): mixed { @@ -272,8 +280,21 @@ function random(iterable $array, int $number = 1, bool $preserveKey = false): mi * Retrieves values from a given key in each sub-array of the current array. * Optionally, you can pass a second parameter to also get the keys following the same pattern. * + * @template TItem of array + * @template TValueKey of string + * @template TIndexKey of string + * + * @param iterable $array * @param string $value The key to assign the values from, support dot notation. * @param string|null $key The key to assign the keys from, support dot notation. + * @phpstan-param TValueKey $value + * @phpstan-param TIndexKey|null $key + * + * @return ( + * $key is null + * ? list<(TValueKey is key-of ? TItem[TValueKey]|null : mixed)> + * : array ? TItem[TValueKey]|null : mixed)> + * ) */ function pluck(iterable $array, string $value, ?string $key = null): array { @@ -308,9 +329,12 @@ function pluck(iterable $array, string $value, ?string $key = null): array * * @template TKey of array-key * @template TValue + * @template TPrepended * * @param iterable $array - * @param TValue $values + * @param TPrepended $values + * + * @return array */ function prepend(iterable $array, mixed ...$values): array { @@ -328,9 +352,12 @@ function prepend(iterable $array, mixed ...$values): array * * @template TKey of array-key * @template TValue + * @template TAppended * * @param iterable $array - * @param TValue $values + * @param TAppended $values + * + * @return array */ function append(iterable $array, mixed ...$values): array { @@ -348,9 +375,12 @@ function append(iterable $array, mixed ...$values): array * * @template TKey of array-key * @template TValue + * @template TPushed * * @param iterable $array - * @param TValue $value + * @param TPushed $value + * + * @return array */ function push(iterable $array, mixed $value): array { @@ -362,6 +392,15 @@ function push(iterable $array, mixed $value): array /** * Pads the array to the specified size with a value. + * + * @template TKey of array-key + * @template TValue + * @template TPad + * + * @param iterable $array + * @param TPad $value + * + * @return array */ function pad(iterable $array, int $size, mixed $value): array { @@ -389,8 +428,14 @@ function flip(iterable $array): array /** * Returns a new array with only unique items from the original array. * - * @param string|null|Closure $key The key to use as the uniqueness criteria in nested arrays. + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * @param string|null|Closure(TValue, array): mixed $key The key to use as the uniqueness criteria in nested arrays. * @param bool $shouldBeStrict Whether the comparison should be strict, only used when giving a key parameter. + * + * @return array|list */ function unique(iterable $array, null|Closure|string $key = null, bool $shouldBeStrict = false): array { @@ -439,6 +484,8 @@ function unique(iterable $array, null|Closure|string $key = null, bool $shouldBe * * @param iterable $array * @param array ...$arrays + * + * @return array */ function diff(iterable $array, array ...$arrays): array { @@ -453,6 +500,8 @@ function diff(iterable $array, array ...$arrays): array * * @param iterable $array * @param array ...$arrays + * + * @return array */ function diff_keys(iterable $array, array ...$arrays): array { @@ -467,6 +516,8 @@ function diff_keys(iterable $array, array ...$arrays): array * * @param iterable $array * @param array ...$arrays + * + * @return array */ function intersect(iterable $array, array ...$arrays): array { @@ -481,6 +532,8 @@ function intersect(iterable $array, array ...$arrays): array * * @param iterable $array * @param array ...$arrays + * + * @return array */ function intersect_keys(iterable $array, array ...$arrays): array { @@ -495,6 +548,8 @@ function intersect_keys(iterable $array, array ...$arrays): array * * @param iterable $array * @param array ...$arrays The arrays to merge. + * + * @return array */ function merge(iterable $array, iterable ...$arrays): array { @@ -621,7 +676,14 @@ function last(iterable $array, ?Closure $filter = null, mixed $default = null): /** * Returns a copy of the given array without the last value. * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array * @param mixed $value The popped value will be stored in this variable. + * @param-out TValue|null $value + * + * @return list */ function pop(iterable $array, mixed &$value = null): array { @@ -634,7 +696,14 @@ function pop(iterable $array, mixed &$value = null): array /** * Returns a copy of the given array without the first value. * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array * @param mixed $value The unshifted value will be stored in this variable + * @param-out TValue|null $value + * + * @return list */ function unshift(iterable $array, mixed &$value = null): array { @@ -646,6 +715,13 @@ function unshift(iterable $array, mixed &$value = null): array /** * Returns a copy of the given array in reverse order. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * + * @return array */ function reverse(iterable $array): array { @@ -713,6 +789,8 @@ function values(iterable $array): array * * @param iterable $array * @param null|Closure(TValue $value, TKey $key): bool $filter + * + * @return array */ function filter(iterable $array, ?Closure $filter = null): array { @@ -735,7 +813,9 @@ function filter(iterable $array, ?Closure $filter = null): array * @template TValue * * @param iterable $array - * @param Closure(TKey $value, TValue $key): void $each + * @param Closure(TValue $value, TKey $key): void $each + * + * @return array */ function each(iterable $array, Closure $each): array { @@ -783,9 +863,13 @@ function map(iterable $array, Closure $map): array * * @template TKey of array-key * @template TValue + * @template TMapKey of array-key + * @template TMapValue * * @param iterable $array - * @param Closure(TValue $value, TKey $key): Generator $map + * @param Closure(TValue $value, TKey $key): Generator $map + * + * @return array */ function map_with_keys(iterable $array, Closure $map): array { @@ -794,7 +878,6 @@ function map_with_keys(iterable $array, Closure $map): array foreach (to_array($array) as $key => $value) { $generator = $map($value, $key); - // @phpstan-ignore instanceof.alwaysTrue if (! $generator instanceof Generator) { throw new MapWithKeysDidNotUseAGenerator(); } @@ -908,6 +991,13 @@ function every(iterable $array, ?Closure $callback = null): bool /** * Returns a copy of the array with the given `$value` associated to the given `$key`. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * + * @return array */ function set_by_key(iterable $array, string $key, mixed $value): array { @@ -945,6 +1035,13 @@ function set_by_key(iterable $array, string $key, mixed $value): array /** * Returns a copy of the array that converts the dot-notated keys to a set of nested arrays. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * + * @return array */ function undot(iterable $array): array { @@ -977,6 +1074,13 @@ function undot(iterable $array): array /** * Returns a copy of the array that converts nested arrays to a single-dimension dot-notation array. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * + * @return array */ function dot(iterable $array, string $prefix = ''): array { @@ -1026,6 +1130,12 @@ function join(iterable $array, string $glue = ', ', ?string $finalGlue = ' and ' * ```php * flatten(['foo', ['bar', 'baz']]); // ['foo', 'bar', 'baz'] * ``` + * + * @template TFlattenValue + * + * @param iterable> $array + * + * @return list */ function flatten(iterable $array, int|float $depth = INF): array { @@ -1078,8 +1188,12 @@ function flatten(iterable $array, int|float $depth = INF): array * * @template TKey of array-key * @template TValue + * @template TGroupKey of array-key + * * @param iterable $array - * @param Closure(TValue, TKey): array-key $keyExtracor + * @param Closure(TValue, TKey): TGroupKey $keyExtracor + * + * @return array> */ function group_by(iterable $array, Closure $keyExtracor): array { @@ -1104,9 +1218,9 @@ function group_by(iterable $array, Closure $keyExtracor): array * @template TValue * * @param iterable $array - * @param Closure(TValue,TKey): TMapValue[] $map + * @param Closure(TValue,TKey): array $map * - * @return array + * @return list */ function flat_map(iterable $array, Closure $map, int|float $depth = 1): array { @@ -1119,7 +1233,13 @@ function flat_map(iterable $array, Closure $map, int|float $depth = 1): array * @see Tempest\Mapper\map() * * @template T + * @template TKey of array-key + * @template TValue + * + * @param iterable $array * @param class-string $to + * + * @return array */ function map_to(iterable $array, string $to): array { @@ -1207,7 +1327,7 @@ function sort_keys(iterable $array, bool $desc = false, int $flags = SORT_REGULA * @template TValue * * @param iterable $array - * @param callable $callback The function to use for comparing keys. It should accept two parameters + * @param callable(TKey, TKey): int $callback The function to use for comparing keys. It should accept two parameters * and return an integer less than, equal to, or greater than zero if the * first argument is considered to be respectively less than, equal to, or * greater than the second. @@ -1229,6 +1349,13 @@ function sort_keys_by_callback(iterable $array, callable $callback): array * ```php * slice([1, 2, 3, 4, 5], 2); // [3, 4, 5] * ``` + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * + * @return list */ function slice(iterable $array, int $offset, ?int $length = null): array { @@ -1320,7 +1447,7 @@ function range(int|float $start, int|float $end, int|float|null $step = null): a * @param iterable $iterable * @param (Closure(T): bool) $predicate * - * @return array{0: array, 1: array} + * @return array{0: list, 1: list} */ function partition(iterable $iterable, Closure $predicate): array { @@ -1369,12 +1496,24 @@ function wrap(mixed $input = []): array * Converts various data structures to a PHP array. * As opposed to `{@see \Tempest\Support\Arr\wrap}`, this function converts {@see Traversable} and {@see Countable} instances to arrays. * - * @param mixed $input Any value that can be converted to an array: - * - Arrays are returned as-is - * - Scalar values are wrapped in an array - * - Traversable objects are converted using `{@see iterator_to_array}` - * - {@see Countable} objects are converted to arrays - * - {@see null} becomes an empty array + * @template TKey of array-key + * @template TValue + * + * @param null|array|ArrayInterface|Traversable|Countable|scalar|object $input Any value that can be converted to an array: + * + * @return ( + * $input is null ? array{} : + * ($input is array ? array : + * ($input is ArrayInterface ? array : + * ($input is Traversable ? array : array))) + * ) + * + * Supported input shapes: + * - Arrays are returned as-is + * - Scalar values are wrapped in an array + * - Traversable objects are converted using `{@see iterator_to_array}` + * - {@see Countable} objects are converted to arrays + * - {@see null} becomes an empty array */ function to_array(mixed $input): array { From 21a6cb1bffd3990ab641852e3b1210fe17efb583 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 00:15:20 +0100 Subject: [PATCH 013/134] refactor(command-bus): remove unused Console dependency --- packages/command-bus/src/HandleAsyncCommand.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/command-bus/src/HandleAsyncCommand.php b/packages/command-bus/src/HandleAsyncCommand.php index ee4e9378f8..7947189fa8 100644 --- a/packages/command-bus/src/HandleAsyncCommand.php +++ b/packages/command-bus/src/HandleAsyncCommand.php @@ -5,7 +5,6 @@ namespace Tempest\CommandBus; use DateTimeImmutable; -use Tempest\Console\Console; use Tempest\Console\ConsoleCommand; use Tempest\Console\ExitCode; use Tempest\Console\HasConsole; @@ -22,7 +21,6 @@ public function __construct( private CommandBusConfig $commandBusConfig, private Container $container, - private Console $console, private CommandRepository $repository, ) {} From e7bc174d6a45c187800caecd7f7692c4e9e68a5a Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 00:58:19 +0100 Subject: [PATCH 014/134] fix(console): correct Process callback signature in TaskComponent --- .../console/src/Components/Interactive/TaskComponent.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/console/src/Components/Interactive/TaskComponent.php b/packages/console/src/Components/Interactive/TaskComponent.php index 6d5960c04e..4ccb73edec 100644 --- a/packages/console/src/Components/Interactive/TaskComponent.php +++ b/packages/console/src/Components/Interactive/TaskComponent.php @@ -172,16 +172,14 @@ private function resolveHandler(null|Process|Closure $handler): ?Closure if ($handler instanceof Process) { return static function (Closure $log) use ($handler): bool { - return $handler->run(function (string $output, string $buffer) use ($log): bool { - if ($output === Process::ERR) { - return true; + return $handler->run(function (string $type, string $buffer) use ($log): void { + if ($type === Process::ERR) { + return; } if ($line = trim($buffer)) { $log($line); } - - return true; }) === 0; }; } From 8c897e373383dcd2a87c59c678e24bfc97127b12 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 00:58:27 +0100 Subject: [PATCH 015/134] refactor(console): remove unused ConsoleCommand dependency --- .../console/src/Exceptions/UnknownArgumentsException.php | 6 +++--- .../src/Middleware/ValidateNamedArgumentsMiddleware.php | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/console/src/Exceptions/UnknownArgumentsException.php b/packages/console/src/Exceptions/UnknownArgumentsException.php index 67cc70dbd0..1d2beb269a 100644 --- a/packages/console/src/Exceptions/UnknownArgumentsException.php +++ b/packages/console/src/Exceptions/UnknownArgumentsException.php @@ -3,15 +3,15 @@ namespace Tempest\Console\Exceptions; use Tempest\Console\Console; -use Tempest\Console\ConsoleCommand; use Tempest\Console\Input\ConsoleInputArgument; use Tempest\Support\Arr\ImmutableArray; final class UnknownArgumentsException extends ConsoleException { + /** + * @param ImmutableArray $invalidArguments + */ public function __construct( - private readonly ConsoleCommand $consoleCommand, - /** @var \Tempest\Console\Input\ConsoleInputArgument[] $invalidArguments */ private readonly ImmutableArray $invalidArguments, ) {} diff --git a/packages/console/src/Middleware/ValidateNamedArgumentsMiddleware.php b/packages/console/src/Middleware/ValidateNamedArgumentsMiddleware.php index f1fdf03700..c7b4cd1435 100644 --- a/packages/console/src/Middleware/ValidateNamedArgumentsMiddleware.php +++ b/packages/console/src/Middleware/ValidateNamedArgumentsMiddleware.php @@ -38,7 +38,6 @@ public function __invoke(Invocation $invocation, ConsoleMiddlewareCallable $next if ($invalidInput->isNotEmpty()) { throw new UnknownArgumentsException( - consoleCommand: $invocation->consoleCommand, invalidArguments: $invalidInput, ); } From bac710aa169c4cd6e5db55795874a5db2afa8abe Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 00:58:34 +0100 Subject: [PATCH 016/134] fix(console): correct PHPDoc and return type annotations --- packages/console/src/Console.php | 2 +- packages/console/src/Testing/ConsoleTester.php | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/console/src/Console.php b/packages/console/src/Console.php index 30b4a574b0..056ce55845 100644 --- a/packages/console/src/Console.php +++ b/packages/console/src/Console.php @@ -53,7 +53,7 @@ public function component(InteractiveConsoleComponent $component, array $validat /** * Asks the user a question and returns the answer. * - * @param null|array|iterable|class-string $options + * @param null|iterable|class-string $options * @param mixed|null $default * @param \Tempest\Validation\Rule[] $validation */ diff --git a/packages/console/src/Testing/ConsoleTester.php b/packages/console/src/Testing/ConsoleTester.php index 073436ed24..088f695c5c 100644 --- a/packages/console/src/Testing/ConsoleTester.php +++ b/packages/console/src/Testing/ConsoleTester.php @@ -313,10 +313,8 @@ public function withPrompting(): self return $this; } - public function dd(): self + public function dd(): never { ld($this->output->asUnformattedString()); - - return $this; } } From 70e1e2583055c9d14593f6a5da7d025b0d5fc0f6 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 00:58:42 +0100 Subject: [PATCH 017/134] refactor(console): simplify config stub files --- packages/console/src/Stubs/console.config.stub.php | 13 ------------- packages/console/src/Stubs/log.config.stub.php | 12 +++--------- .../Console/Commands/MakeConfigCommandTest.php | 4 ++-- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/packages/console/src/Stubs/console.config.stub.php b/packages/console/src/Stubs/console.config.stub.php index b4efa218d7..06c4060ba4 100644 --- a/packages/console/src/Stubs/console.config.stub.php +++ b/packages/console/src/Stubs/console.config.stub.php @@ -3,20 +3,7 @@ declare(strict_types=1); use Tempest\Console\ConsoleConfig; -use Tempest\Console\Middleware\ConsoleExceptionMiddleware; -use Tempest\Console\Middleware\HelpMiddleware; -use Tempest\Console\Middleware\InvalidCommandMiddleware; -use Tempest\Console\Middleware\OverviewMiddleware; -use Tempest\Console\Middleware\ResolveOrRescueMiddleware; -use Tempest\Core\Middleware; return new ConsoleConfig( name: 'Console Name', - middleware: new Middleware( - OverviewMiddleware::class, - ConsoleExceptionMiddleware::class, - ResolveOrRescueMiddleware::class, - InvalidCommandMiddleware::class, - HelpMiddleware::class, - ), ); diff --git a/packages/console/src/Stubs/log.config.stub.php b/packages/console/src/Stubs/log.config.stub.php index 4dc3c310cb..9e8dc0151f 100644 --- a/packages/console/src/Stubs/log.config.stub.php +++ b/packages/console/src/Stubs/log.config.stub.php @@ -2,14 +2,8 @@ declare(strict_types=1); -use Tempest\Log\Channels\AppendLogChannel; -use Tempest\Log\LogConfig; +use Tempest\Log\Config\SimpleLogConfig; -return new LogConfig( - channels: [ - new AppendLogChannel( - path: __DIR__ . '/../logs/project.log', - ), - ], - serverLogPath: '/path/to/nginx.log', +return new SimpleLogConfig( + path: __DIR__ . '/../logs/project.log', ); diff --git a/tests/Integration/Console/Commands/MakeConfigCommandTest.php b/tests/Integration/Console/Commands/MakeConfigCommandTest.php index 0f0766b346..66ebae005a 100644 --- a/tests/Integration/Console/Commands/MakeConfigCommandTest.php +++ b/tests/Integration/Console/Commands/MakeConfigCommandTest.php @@ -11,7 +11,7 @@ use Tempest\Console\Enums\ConfigType; use Tempest\Database\Config\MysqlConfig; use Tempest\EventBus\EventBusConfig; -use Tempest\Log\LogConfig; +use Tempest\Log\Config\SimpleLogConfig; use Tempest\Support\Namespace\Psr4Namespace; use Tempest\View\Renderers\BladeConfig; use Tempest\View\Renderers\TwigConfig; @@ -89,7 +89,7 @@ public static function config_type_provider(): array ], 'log_config' => [ 'configType' => ConfigType::LOG, - 'expectedConfigClass' => LogConfig::class, + 'expectedConfigClass' => SimpleLogConfig::class, ], 'console_config' => [ 'configType' => ConfigType::CONSOLE, From 6a9eb9c74ddfa3cfea5c400d5f59433c17ae007e Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 00:59:00 +0100 Subject: [PATCH 018/134] fix(console): remove redundant assertions in ShellTest --- packages/console/tests/Enums/ShellTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/console/tests/Enums/ShellTest.php b/packages/console/tests/Enums/ShellTest.php index dcf569e969..c2f3b87f90 100644 --- a/packages/console/tests/Enums/ShellTest.php +++ b/packages/console/tests/Enums/ShellTest.php @@ -96,12 +96,10 @@ public function getRcFile(): void public function getPostInstallInstructions(): void { $zshInstructions = Shell::ZSH->getPostInstallInstructions(); - $this->assertIsArray($zshInstructions); $this->assertNotEmpty($zshInstructions); $this->assertStringContainsString('fpath', $zshInstructions[0]); $bashInstructions = Shell::BASH->getPostInstallInstructions(); - $this->assertIsArray($bashInstructions); $this->assertNotEmpty($bashInstructions); $this->assertStringContainsStringIgnoringCase('source', $bashInstructions[0]); } From 6c3dd1840adf4d086bb0c951f22a657a48f9afb0 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:11:24 +0100 Subject: [PATCH 019/134] refactor(phpstan): update baseline and config --- phpstan-baseline.neon | 45 +++++++++++++++++++++++++++- phpstan.neon.dist | 68 +++++++++++++++++++++---------------------- 2 files changed, 77 insertions(+), 36 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8366ae2fac..f84e581601 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,2 +1,45 @@ parameters: - ignoreErrors: [] \ No newline at end of file + ignoreErrors: + - + identifier: argument.named + paths: + - src/Tempest/Framework/Testing/* + - tests/Integration/**/*Test.php + - packages/auth/src/OAuth/Testing/* + - packages/cache/src/Testing/* + - + identifier: smaller.alwaysFalse + path: packages/support/src/Arr/functions.php + count: 1 + # Runtime guard: positive-int type guarantees $number >= 1, but we keep the check for safety + - + identifier: instanceof.alwaysTrue + path: packages/support/src/Arr/functions.php + count: 1 + # Runtime guard: closure return type guarantees Generator, but we validate for a clear error message + - + identifier: disallowed.function + path: packages/clock/src/MockClock.php + count: 1 + # Intentional: dd() is the purpose of MockClock::dd() + - + identifier: property.uninitializedReadonly + paths: + - packages/**/*Command.php + - src/Tempest/Framework/Commands/*Command.php + - packages/console/src/Stubs/ConsoleMiddlewareStub.php + - src/Tempest/Framework/Installers/ViewComponentsInstaller.php + - tests/Fixtures/**/*Command.php + - tests/Fixtures/Console/CommandWithArgumentName.php + - tests/Integration/Console/Fixtures/*Command.php + # Intentional: #[Inject] attribute on HasConsole trait initializes $console via container reflection after construction + - + identifier: disallowed.function + path: packages/console/src/Terminal/Terminal.php + count: 3 + # Intentional: exec() is required for TTY terminal operations (stty, tput) + - + identifier: function.alreadyNarrowedType + path: packages/console/src/GenericConsole.php + count: 1 + # Runtime guard: PHPDoc narrows string to class-string, but is_a() check is needed at runtime diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d2dc5f775b..8daadbb7e5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,39 +1,37 @@ includes: - - phpstan-baseline.neon - - vendor/spaze/phpstan-disallowed-calls/extension.neon - - vendor/phpat/phpat/extension.neon + - phpstan-baseline.neon + - vendor/spaze/phpstan-disallowed-calls/extension.neon + - vendor/phpat/phpat/extension.neon services: - - - class: Tests\Tempest\Architecture\ArchitectureTestCase - tags: - - phpat.test + - + class: Tests\Tempest\Architecture\ArchitectureTestCase + tags: + - phpat.test parameters: - level: 5 - tmpDir: .cache/phpstan - tips: - treatPhpDocTypesAsCertain: false - excludePaths: - - tests/Integration/View/blade/cache/**.php - paths: - - src - - tests - ignoreErrors: - - - identifier: argument.named - - - message: '#.*exec*#' - path: packages/console/src/Terminal/Terminal.php + level: 5 + tmpDir: .cache/phpstan + tips: + treatPhpDocTypesAsCertain: false + paths: + - src + - packages/auth + - packages/cache + - packages/clock + - packages/command-bus + - packages/console + - tests + reportUnmatchedIgnoredErrors: true - disallowedFunctionCalls: - - - function: 'exec()' - - - function: 'eval()' - - - function: 'dd()' - - - function: 'dump()' - - - function: 'phpinfo()' - - - function: 'var_dump()' + disallowedFunctionCalls: + - + function: 'exec()' + - + function: 'eval()' + - + function: 'dd()' + - + function: 'dump()' + - + function: 'phpinfo()' + - + function: 'var_dump()' From fb23b8f20adae74af4ce6ac818f5d7dec3b4101c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:24:56 +0100 Subject: [PATCH 020/134] chore: update phpstan command --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 75b6fa585e..2f53c1ad18 100644 --- a/composer.json +++ b/composer.json @@ -253,7 +253,7 @@ "test": "composer phpunit", "test:stop": "composer phpunit -- --stop-on-error --stop-on-failure", "lint": "vendor/bin/mago lint --potentially-unsafe --minimum-fail-level=note", - "phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G", + "phpstan": "vendor/bin/phpstan analyse --memory-limit=1G", "rector": "vendor/bin/rector process --no-ansi", "merge": "php -d\"error_reporting = E_ALL & ~E_DEPRECATED\" vendor/bin/monorepo-builder merge", "intl:plural": "./packages/intl/bin/plural-rules.php", From fa031101dca0bc4fb83656809a1a36d2f2a953a8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:25:31 +0100 Subject: [PATCH 021/134] refactor(container): add missing final keyword to fixtures --- packages/container/tests/Fixtures/AutowireWithEnumTags.php | 2 +- packages/container/tests/Fixtures/DecoratedClass.php | 2 +- packages/container/tests/Fixtures/DecoratorClass.php | 2 +- packages/container/tests/Fixtures/DecoratorInvalid.php | 2 +- packages/container/tests/Fixtures/DecoratorSecondClass.php | 2 +- .../container/tests/Fixtures/DecoratorWithoutConstructor.php | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/container/tests/Fixtures/AutowireWithEnumTags.php b/packages/container/tests/Fixtures/AutowireWithEnumTags.php index d4caa8d495..447bfde100 100644 --- a/packages/container/tests/Fixtures/AutowireWithEnumTags.php +++ b/packages/container/tests/Fixtures/AutowireWithEnumTags.php @@ -4,7 +4,7 @@ use Tempest\Container\Tag; -class AutowireWithEnumTags +final class AutowireWithEnumTags { public function __construct( #[Tag(EnumTag::FOO)] diff --git a/packages/container/tests/Fixtures/DecoratedClass.php b/packages/container/tests/Fixtures/DecoratedClass.php index 4664908c60..da11df34ad 100644 --- a/packages/container/tests/Fixtures/DecoratedClass.php +++ b/packages/container/tests/Fixtures/DecoratedClass.php @@ -4,6 +4,6 @@ namespace Tempest\Container\Tests\Fixtures; -class DecoratedClass implements DecoratedInterface +final class DecoratedClass implements DecoratedInterface { } diff --git a/packages/container/tests/Fixtures/DecoratorClass.php b/packages/container/tests/Fixtures/DecoratorClass.php index ef07523121..3e8d3577bc 100644 --- a/packages/container/tests/Fixtures/DecoratorClass.php +++ b/packages/container/tests/Fixtures/DecoratorClass.php @@ -7,7 +7,7 @@ use Tempest\Container\Decorates; #[Decorates(DecoratedInterface::class)] -class DecoratorClass implements DecoratedInterface +final class DecoratorClass implements DecoratedInterface { public function __construct( public DecoratedInterface $decorated, diff --git a/packages/container/tests/Fixtures/DecoratorInvalid.php b/packages/container/tests/Fixtures/DecoratorInvalid.php index 131715af8a..a7205e2acb 100644 --- a/packages/container/tests/Fixtures/DecoratorInvalid.php +++ b/packages/container/tests/Fixtures/DecoratorInvalid.php @@ -7,7 +7,7 @@ use Tempest\Container\Decorates; #[Decorates(DecoratedInterface::class)] -class DecoratorInvalid +final class DecoratorInvalid { public function __construct( public DecoratedInterface $decorated, diff --git a/packages/container/tests/Fixtures/DecoratorSecondClass.php b/packages/container/tests/Fixtures/DecoratorSecondClass.php index 93084edfc6..6fe9129c3a 100644 --- a/packages/container/tests/Fixtures/DecoratorSecondClass.php +++ b/packages/container/tests/Fixtures/DecoratorSecondClass.php @@ -7,7 +7,7 @@ use Tempest\Container\Decorates; #[Decorates(DecoratedInterface::class)] -class DecoratorSecondClass implements DecoratedInterface +final class DecoratorSecondClass implements DecoratedInterface { public function __construct( public DecoratedInterface $decorated, diff --git a/packages/container/tests/Fixtures/DecoratorWithoutConstructor.php b/packages/container/tests/Fixtures/DecoratorWithoutConstructor.php index 72e79034ab..6adabcc371 100644 --- a/packages/container/tests/Fixtures/DecoratorWithoutConstructor.php +++ b/packages/container/tests/Fixtures/DecoratorWithoutConstructor.php @@ -7,6 +7,6 @@ use Tempest\Container\Decorates; #[Decorates(DecoratedInterface::class)] -class DecoratorWithoutConstructor implements DecoratedInterface +final class DecoratorWithoutConstructor implements DecoratedInterface { } From f2c61125791a9d6a6ac57527256f91f3676b5d03 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:25:54 +0100 Subject: [PATCH 022/134] refactor(auth): add mago-expect for test --- packages/auth/tests/AuthenticationAndOAuthSafetyTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth/tests/AuthenticationAndOAuthSafetyTest.php b/packages/auth/tests/AuthenticationAndOAuthSafetyTest.php index 91beb330d3..f3e8732d0d 100644 --- a/packages/auth/tests/AuthenticationAndOAuthSafetyTest.php +++ b/packages/auth/tests/AuthenticationAndOAuthSafetyTest.php @@ -31,10 +31,10 @@ public function generic_oauth_client_state_is_null_before_generating_authorizati { $provider = new GenericProvider([ 'clientId' => 'client-id', - 'clientSecret' => 'client-secret', + 'clientSecret' => 'client-secret', // @mago-expect lint:no-literal-password 'redirectUri' => 'https://example.com/callback', 'urlAuthorize' => 'https://provider.test/authorize', - 'urlAccessToken' => 'https://provider.test/token', + 'urlAccessToken' => 'https://provider.test/token', // @mago-expect lint:no-literal-password 'urlResourceOwnerDetails' => 'https://provider.test/user', ]); From 2f685863532522d83cd8acf94b27b90156574399 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:39:56 +0100 Subject: [PATCH 023/134] refactor: extract class constants into backed enum --- .../src/InternalCacheInsightsProvider.php | 13 ++++++----- .../cache/src/UserCacheInsightsProvider.php | 5 +++-- .../core/src/EnvironmentInsightsProvider.php | 6 ++--- packages/core/src/Insight.php | 22 +++++++------------ packages/core/src/InsightType.php | 13 +++++++++++ .../database/src/DatabaseInsightsProvider.php | 5 +++-- packages/intl/src/IntlInsightsProvider.php | 3 ++- .../src/Redis/RedisInsightsProvider.php | 7 +++--- 8 files changed, 43 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/InsightType.php diff --git a/packages/cache/src/InternalCacheInsightsProvider.php b/packages/cache/src/InternalCacheInsightsProvider.php index 5e8cd9b788..b1b0d039c9 100644 --- a/packages/cache/src/InternalCacheInsightsProvider.php +++ b/packages/cache/src/InternalCacheInsightsProvider.php @@ -7,6 +7,7 @@ use Tempest\Core\DiscoveryCacheStrategy; use Tempest\Core\Insight; use Tempest\Core\InsightsProvider; +use Tempest\Core\InsightType; use Tempest\Icon\IconCache; use Tempest\View\ViewCache; @@ -25,14 +26,14 @@ public function getInsights(): array { return [ 'Discovery' => match ($this->discoveryCache->valid) { - false => new Insight('Invalid', Insight::ERROR), + false => new Insight('Invalid', InsightType::ERROR), true => match ($this->discoveryCache->enabled) { true => match ($this->discoveryCache->strategy) { - DiscoveryCacheStrategy::FULL => new Insight('Enabled', Insight::SUCCESS), - DiscoveryCacheStrategy::PARTIAL => new Insight('Enabled (partial)', Insight::SUCCESS), + DiscoveryCacheStrategy::FULL => new Insight('Enabled', InsightType::SUCCESS), + DiscoveryCacheStrategy::PARTIAL => new Insight('Enabled (partial)', InsightType::SUCCESS), default => null, // INVALID and NONE are handled }, - false => new Insight('Disabled', Insight::WARNING), + false => new Insight('Disabled', InsightType::WARNING), }, }, 'Configuration' => $this->getInsight($this->configCache->enabled), @@ -44,9 +45,9 @@ public function getInsights(): array private function getInsight(bool $enabled): Insight { if ($enabled) { - return new Insight('ENABLED', Insight::SUCCESS); + return new Insight('ENABLED', InsightType::SUCCESS); } - return new Insight('DISABLED', Insight::WARNING); + return new Insight('DISABLED', InsightType::WARNING); } } diff --git a/packages/cache/src/UserCacheInsightsProvider.php b/packages/cache/src/UserCacheInsightsProvider.php index 6cd359e84c..81d1ef18e5 100644 --- a/packages/cache/src/UserCacheInsightsProvider.php +++ b/packages/cache/src/UserCacheInsightsProvider.php @@ -13,6 +13,7 @@ use Tempest\Container\GenericContainer; use Tempest\Core\Insight; use Tempest\Core\InsightsProvider; +use Tempest\Core\InsightType; use Tempest\Support\Str; use UnitEnum; @@ -54,10 +55,10 @@ private function getInsight(Cache $cache): array : null; if ($cache->enabled) { - return [$type, new Insight('ENABLED', Insight::SUCCESS)]; + return [$type, new Insight('ENABLED', InsightType::SUCCESS)]; } - return [$type, new Insight('DISABLED', Insight::WARNING)]; + return [$type, new Insight('DISABLED', InsightType::WARNING)]; } private function getCacheName(Cache $cache): string diff --git a/packages/core/src/EnvironmentInsightsProvider.php b/packages/core/src/EnvironmentInsightsProvider.php index 7ab27261fa..07c333756a 100644 --- a/packages/core/src/EnvironmentInsightsProvider.php +++ b/packages/core/src/EnvironmentInsightsProvider.php @@ -21,7 +21,7 @@ public function getInsights(): array 'Composer version' => $this->getComposerVersion(), 'Operating system' => $this->getOperatingSystem(), 'Environment' => $this->environment->value, - 'Application URL' => $this->appConfig->baseUri ?: new Insight('Not set', Insight::ERROR), + 'Application URL' => $this->appConfig->baseUri ?: new Insight('Not set', InsightType::ERROR), ]; } @@ -34,14 +34,14 @@ private function getComposerVersion(): Insight|string $output = shell_exec('composer --version --no-ansi 2>&1'); if (! $output) { - return new Insight('Not installed', Insight::ERROR); + return new Insight('Not installed', InsightType::ERROR); } return \Tempest\Support\Regex\get_match( subject: $output, pattern: "/Composer version (?\S+)/", match: 'version', - default: new Insight('Unknown', Insight::ERROR), + default: new Insight('Unknown', InsightType::ERROR), ); } diff --git a/packages/core/src/Insight.php b/packages/core/src/Insight.php index 079662d45b..c5d22bf973 100644 --- a/packages/core/src/Insight.php +++ b/packages/core/src/Insight.php @@ -1,28 +1,22 @@ match ($this->type) { - self::ERROR => "" . mb_strtoupper($this->value) . '', - self::SUCCESS => "" . mb_strtoupper($this->value) . '', - self::WARNING => "" . mb_strtoupper($this->value) . '', - self::NORMAL => $this->value, + InsightType::ERROR => "" . mb_strtoupper($this->value) . '', + InsightType::SUCCESS => "" . mb_strtoupper($this->value) . '', + InsightType::WARNING => "" . mb_strtoupper($this->value) . '', + InsightType::NORMAL => $this->value, }; } public function __construct( - private(set) string $value, - private string $type = self::NORMAL, + private(set) readonly string $value, + private readonly InsightType $type = InsightType::NORMAL, ) {} } diff --git a/packages/core/src/InsightType.php b/packages/core/src/InsightType.php new file mode 100644 index 0000000000..61e920ea07 --- /dev/null +++ b/packages/core/src/InsightType.php @@ -0,0 +1,13 @@ + $this->intlConfig->currentLocale->getDisplayLanguage(), 'Fallback locale' => $this->intlConfig->fallbackLocale->getDisplayLanguage(), 'Translation files' => (string) arr($this->intlConfig->translationMessagePaths)->flatten()->count(), - 'Intl extension' => extension_loaded('intl') ? new Insight('ENABLED', Insight::SUCCESS) : new Insight('DISABLED', Insight::WARNING), + 'Intl extension' => extension_loaded('intl') ? new Insight('ENABLED', InsightType::SUCCESS) : new Insight('DISABLED', InsightType::WARNING), ]; } } diff --git a/packages/kv-store/src/Redis/RedisInsightsProvider.php b/packages/kv-store/src/Redis/RedisInsightsProvider.php index 90428cc9ee..7ef93de84f 100644 --- a/packages/kv-store/src/Redis/RedisInsightsProvider.php +++ b/packages/kv-store/src/Redis/RedisInsightsProvider.php @@ -6,6 +6,7 @@ use Tempest\Container\Container; use Tempest\Core\Insight; use Tempest\Core\InsightsProvider; +use Tempest\Core\InsightType; use Tempest\Support\Regex; final class RedisInsightsProvider implements InsightsProvider @@ -28,13 +29,13 @@ public function getInsights(): array 'Engine' => match (get_class($redis->getClient())) { \Redis::class => 'Redis extension', Predis\Client::class => 'Predis', - default => new Insight('None', Insight::WARNING), + default => new Insight('None', InsightType::WARNING), }, - 'Version' => $version ?: new Insight('Unknown', Insight::WARNING), + 'Version' => $version ?: new Insight('Unknown', InsightType::WARNING), ]; } catch (\Throwable) { return [ - 'Engine' => new Insight('Disconnected', Insight::ERROR), + 'Engine' => new Insight('Disconnected', InsightType::ERROR), ]; } } From f1137a722cfbaf073abd2a4c12f5ea15f6cf000d Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:40:41 +0100 Subject: [PATCH 024/134] refactor(core): remove unused Environment dependency --- packages/core/src/Commands/DiscoveryGenerateCommand.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/Commands/DiscoveryGenerateCommand.php b/packages/core/src/Commands/DiscoveryGenerateCommand.php index 16faee3bec..ca286ddfa6 100644 --- a/packages/core/src/Commands/DiscoveryGenerateCommand.php +++ b/packages/core/src/Commands/DiscoveryGenerateCommand.php @@ -12,7 +12,6 @@ use Tempest\Core\DiscoveryCache; use Tempest\Core\DiscoveryCacheStrategy; use Tempest\Core\DiscoveryConfig; -use Tempest\Core\Environment; use Tempest\Core\FrameworkKernel; use Tempest\Core\Kernel; use Tempest\Core\Kernel\LoadDiscoveryClasses; @@ -25,7 +24,6 @@ public function __construct( private Kernel $kernel, private DiscoveryCache $discoveryCache, - private Environment $environment, ) {} #[ConsoleCommand( From c2ae59c9ceaa069dcfe444d204ccfb77d6b6b0b0 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:40:46 +0100 Subject: [PATCH 025/134] fix(core): correct PHPDoc type annotation --- packages/core/src/Kernel/LoadDiscoveryClasses.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/Kernel/LoadDiscoveryClasses.php b/packages/core/src/Kernel/LoadDiscoveryClasses.php index 561dc1de08..ffc1555adc 100644 --- a/packages/core/src/Kernel/LoadDiscoveryClasses.php +++ b/packages/core/src/Kernel/LoadDiscoveryClasses.php @@ -96,7 +96,7 @@ public function build( /** * Build a list of discovery classes within all registered discovery locations * @param Discovery[] $discoveries - * @param DiscoveryLocation[]|null $discoveryLocations + * @param DiscoveryLocation[] $discoveryLocations */ private function discover(array $discoveries, array $discoveryLocations): void { From 3041dbab0061d049898c664500c6dd47a5d6b7f2 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 01:40:51 +0100 Subject: [PATCH 026/134] chore(phpstan): add container and core to scan paths --- phpstan-baseline.neon | 13 +++++++++++-- phpstan.neon.dist | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f84e581601..a7b8999e47 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -7,6 +7,7 @@ parameters: - tests/Integration/**/*Test.php - packages/auth/src/OAuth/Testing/* - packages/cache/src/Testing/* + - packages/core/src/Exceptions/ExceptionTester.php - identifier: smaller.alwaysFalse path: packages/support/src/Arr/functions.php @@ -26,13 +27,21 @@ parameters: identifier: property.uninitializedReadonly paths: - packages/**/*Command.php + - packages/**/*Installer.php + - packages/console/src/Stubs/*Stub.php - src/Tempest/Framework/Commands/*Command.php - - packages/console/src/Stubs/ConsoleMiddlewareStub.php - - src/Tempest/Framework/Installers/ViewComponentsInstaller.php + - src/Tempest/Framework/Installers/* - tests/Fixtures/**/*Command.php - tests/Fixtures/Console/CommandWithArgumentName.php + - tests/Fixtures/Core/PublishesFilesConcreteClass.php + - tests/Fixtures/TestInstaller.php - tests/Integration/Console/Fixtures/*Command.php # Intentional: #[Inject] attribute on HasConsole trait initializes $console via container reflection after construction + - + identifier: disallowed.function + path: packages/core/src/Composer.php + count: 1 + # Intentional: exec() is required for running composer commands - identifier: disallowed.function path: packages/console/src/Terminal/Terminal.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8daadbb7e5..f509585837 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -19,6 +19,8 @@ parameters: - packages/clock - packages/command-bus - packages/console + - packages/container + - packages/core - tests reportUnmatchedIgnoredErrors: true From 55222f91fed0bd0ec7de239cfbadd7ee587c2d1d Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:08:35 +0100 Subject: [PATCH 027/134] chore(phpstan): add cryptography to scan paths --- phpstan-baseline.neon | 2 ++ phpstan.neon.dist | 1 + 2 files changed, 3 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a7b8999e47..351d6873a9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -8,6 +8,8 @@ parameters: - packages/auth/src/OAuth/Testing/* - packages/cache/src/Testing/* - packages/core/src/Exceptions/ExceptionTester.php + - packages/**/tests/**/*.php + - packages/**/tests/*.php - identifier: smaller.alwaysFalse path: packages/support/src/Arr/functions.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f509585837..81741aabbc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -21,6 +21,7 @@ parameters: - packages/console - packages/container - packages/core + - packages/cryptography - tests reportUnmatchedIgnoredErrors: true From 5e5e55527252899f206786883eb75da987d9cea6 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:08:44 +0100 Subject: [PATCH 028/134] refactor(cryptography): fix PHPStan issues in tests --- packages/cryptography/tests/CreatesSigner.php | 7 +------ .../cryptography/tests/Encryption/EncryptionKeyTest.php | 4 ++-- packages/cryptography/tests/Signing/SignerTest.php | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/cryptography/tests/CreatesSigner.php b/packages/cryptography/tests/CreatesSigner.php index b8cefe4ad3..df06476c8b 100644 --- a/packages/cryptography/tests/CreatesSigner.php +++ b/packages/cryptography/tests/CreatesSigner.php @@ -5,7 +5,6 @@ use Tempest\Clock\Clock; use Tempest\Clock\GenericClock; use Tempest\Cryptography\Signing\GenericSigner; -use Tempest\Cryptography\Signing\SigningAlgorithm; use Tempest\Cryptography\Signing\SigningConfig; use Tempest\Cryptography\Timelock; @@ -14,11 +13,7 @@ trait CreatesSigner private function createSigner(SigningConfig $config, ?Clock $clock = null): GenericSigner { return new GenericSigner( - config: $config ?? new SigningConfig( - algorithm: SigningAlgorithm::SHA256, - key: 'my_secret_key', - minimumExecutionDuration: false, - ), + config: $config, timelock: new Timelock($clock ?? new GenericClock()), ); } diff --git a/packages/cryptography/tests/Encryption/EncryptionKeyTest.php b/packages/cryptography/tests/Encryption/EncryptionKeyTest.php index f92c9bf4b3..cc9323c243 100644 --- a/packages/cryptography/tests/Encryption/EncryptionKeyTest.php +++ b/packages/cryptography/tests/Encryption/EncryptionKeyTest.php @@ -13,8 +13,8 @@ public function test_encryption_key(): void { $key = EncryptionKey::fromString('6+M/ai/szdyR+4NYJxbLhYGCdpSZPrdvZ51S83HLWrQ=', EncryptionAlgorithm::AES_256_GCM); - $this->assertNotNull($key->value); - $this->assertTrue(strlen($key->value) === $key->algorithm->getKeyLength()); + $this->assertNotEmpty($key->value); + $this->assertSame(strlen($key->value), $key->algorithm->getKeyLength()); $this->assertSame(EncryptionAlgorithm::AES_256_GCM, $key->algorithm); } diff --git a/packages/cryptography/tests/Signing/SignerTest.php b/packages/cryptography/tests/Signing/SignerTest.php index 8d003b7f63..eb70a22a6f 100644 --- a/packages/cryptography/tests/Signing/SignerTest.php +++ b/packages/cryptography/tests/Signing/SignerTest.php @@ -83,7 +83,7 @@ public function test_no_signing_key(): void $signer = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, - key: '', + key: '', // @phpstan-ignore argument.type minimumExecutionDuration: false, )); From 821c429a6bdbf82d951a2dec737e3887114d8e65 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:34:00 +0100 Subject: [PATCH 029/134] chore(phpstan): add datetime to scan paths and disallow ld/lw --- phpstan.neon.dist | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 81741aabbc..e7318653b4 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,6 +22,7 @@ parameters: - packages/container - packages/core - packages/cryptography + - packages/datetime - tests reportUnmatchedIgnoredErrors: true @@ -34,6 +35,10 @@ parameters: function: 'dd()' - function: 'dump()' + - + function: 'ld()' + - + function: 'lw()' - function: 'phpinfo()' - From 8fd3e2586d3b50f0fdfaab783dee7d13568dd3ea Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:34:03 +0100 Subject: [PATCH 030/134] refactor(datetime): fix incorrect @throws on convenience methods --- packages/datetime/src/DateTimeConvenienceMethods.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/datetime/src/DateTimeConvenienceMethods.php b/packages/datetime/src/DateTimeConvenienceMethods.php index 88388015ce..24536f7eed 100644 --- a/packages/datetime/src/DateTimeConvenienceMethods.php +++ b/packages/datetime/src/DateTimeConvenienceMethods.php @@ -338,8 +338,7 @@ public function isLeapYear(): bool /** * Adds a year to this date-time object, returning a new instance with the added year. * - * @throws Exception\UnderflowException If adding the years results in an arithmetic underflow. - * @throws Exception\OverflowException If adding the years results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If adding the year results in an arithmetic issue. */ public function plusYear(): static { @@ -359,8 +358,7 @@ public function plusYears(int $years): static /** * Subtracts a year from this date-time object, returning a new instance with the subtracted year. * - * @throws Exception\UnderflowException If subtracting the years results in an arithmetic underflow. - * @throws Exception\OverflowException If subtracting the years results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If subtracting the year results in an arithmetic issue. */ public function minusYear(): static { @@ -380,8 +378,7 @@ public function minusYears(int $years): static /** * Adds a month to this date-time object, returning a new instance with the added month. * - * @throws Exception\UnderflowException If adding the months results in an arithmetic underflow. - * @throws Exception\OverflowException If adding the months results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If adding the month results in an arithmetic issue. */ public function plusMonth(): static { @@ -424,8 +421,7 @@ public function plusMonths(int $months): static /** * Subtracts a month from this date-time object, returning a new instance with the subtracted month. * - * @throws Exception\UnderflowException If subtracting the months results in an arithmetic underflow. - * @throws Exception\OverflowException If subtracting the months results in an arithmetic overflow. + * @throws Exception\UnexpectedValueException If subtracting the month results in an arithmetic issue. */ public function minusMonth(): static { From dad2a586bb832a8299246335d8268c7fdc798c1a Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:34:07 +0100 Subject: [PATCH 031/134] fix(datetime): handle IntlCalendar edge cases for extreme year values --- packages/datetime/src/DateTime.php | 42 ++++++++++++++++-------- packages/datetime/src/functions.php | 13 +++++++- packages/datetime/tests/DateTimeTest.php | 34 +++++++++++++++++++ 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/packages/datetime/src/DateTime.php b/packages/datetime/src/DateTime.php index f1ab03f74b..830da8487d 100644 --- a/packages/datetime/src/DateTime.php +++ b/packages/datetime/src/DateTime.php @@ -171,28 +171,42 @@ public static function fromParts(Timezone $timezone, int $year, Month|int $month $seconds, ); - if ($seconds !== $calendar->get(IntlCalendar::FIELD_SECOND)) { - throw Exception\UnexpectedValueException::forSeconds($seconds, $calendar->get(IntlCalendar::FIELD_SECOND)); + $calendarYear = $calendar->get(IntlCalendar::FIELD_YEAR); + $calendarMonth = $calendar->get(IntlCalendar::FIELD_MONTH); + $calendarDay = $calendar->get(IntlCalendar::FIELD_DAY_OF_MONTH); + $calendarHours = $calendar->get(IntlCalendar::FIELD_HOUR_OF_DAY); + $calendarMinutes = $calendar->get(IntlCalendar::FIELD_MINUTE); + $calendarSeconds = $calendar->get(IntlCalendar::FIELD_SECOND); + + if ($calendarYear === false || $calendarMonth === false || $calendarDay === false || $calendarHours === false || $calendarMinutes === false || $calendarSeconds === false) { + throw new Exception\OverflowException(sprintf( + 'The year value "%d" exceeds the range supported by the calendar.', + $year, + )); } - if ($minutes !== $calendar->get(IntlCalendar::FIELD_MINUTE)) { - throw Exception\UnexpectedValueException::forMinutes($minutes, $calendar->get(IntlCalendar::FIELD_MINUTE)); + if ($seconds !== $calendarSeconds) { + throw Exception\UnexpectedValueException::forSeconds($seconds, $calendarSeconds); } - if ($hours !== $calendar->get(IntlCalendar::FIELD_HOUR_OF_DAY)) { - throw Exception\UnexpectedValueException::forHours($hours, $calendar->get(IntlCalendar::FIELD_HOUR_OF_DAY)); + if ($minutes !== $calendarMinutes) { + throw Exception\UnexpectedValueException::forMinutes($minutes, $calendarMinutes); } - if ($day !== $calendar->get(IntlCalendar::FIELD_DAY_OF_MONTH)) { - throw Exception\UnexpectedValueException::forDay($day, $calendar->get(IntlCalendar::FIELD_DAY_OF_MONTH)); + if ($hours !== $calendarHours) { + throw Exception\UnexpectedValueException::forHours($hours, $calendarHours); } - if ($month !== ($calendar->get(IntlCalendar::FIELD_MONTH) + 1)) { - throw Exception\UnexpectedValueException::forMonth($month, $calendar->get(IntlCalendar::FIELD_MONTH) + 1); + if ($day !== $calendarDay) { + throw Exception\UnexpectedValueException::forDay($day, $calendarDay); } - if ($year !== $calendar->get(IntlCalendar::FIELD_YEAR)) { - throw Exception\UnexpectedValueException::forYear($year, $calendar->get(IntlCalendar::FIELD_YEAR)); + if ($month !== ($calendarMonth + 1)) { + throw Exception\UnexpectedValueException::forMonth($month, $calendarMonth + 1); + } + + if ($year !== $calendarYear) { + throw Exception\UnexpectedValueException::forYear($year, $calendarYear); } $timestamp_in_seconds = (int) ($calendar->getTime() / (float) MILLISECONDS_PER_SECOND); @@ -263,9 +277,11 @@ public static function parse(NativeDateTimeInterface|TemporalInterface|string|in } if ($string instanceof NativeDateTimeInterface) { + $nativeTimezone = $string->getTimezone(); + return self::fromTimestamp( timestamp: Timestamp::fromParts($string->getTimestamp()), - timezone: $timezone ?? Timezone::tryFrom($string->getTimezone()?->getName() ?? ''), + timezone: $timezone ?? ($nativeTimezone instanceof \DateTimeZone ? Timezone::tryFrom($nativeTimezone->getName()) : null), ); } diff --git a/packages/datetime/src/functions.php b/packages/datetime/src/functions.php index 0c4a9fc044..63233bcf7d 100644 --- a/packages/datetime/src/functions.php +++ b/packages/datetime/src/functions.php @@ -7,6 +7,7 @@ use IntlTimeZone; use RuntimeException; use Tempest\DateTime\DateStyle; +use Tempest\DateTime\Exception\OverflowException; use Tempest\DateTime\Exception\ParserException; use Tempest\DateTime\FormatPattern; use Tempest\DateTime\SecondsStyle; @@ -14,6 +15,7 @@ use Tempest\DateTime\TimeStyle; use Tempest\DateTime\Timezone; use Tempest\Intl\Locale; +use ValueError; use function hrtime; use function microtime; @@ -287,7 +289,16 @@ function create_intl_calendar_from_date_time( */ $calendar = IntlCalendar::createInstance(to_intl_timezone($timezone)); - $calendar->setDateTime($year, $month - 1, $day, $hours, $minutes, $seconds); + try { + $calendar->setDateTime($year, $month - 1, $day, $hours, $minutes, $seconds); + } catch (ValueError) { + throw new OverflowException(sprintf( + 'The year value "%d" exceeds the supported calendar range (%d to %d).', + $year, + -2_147_483_648, + 2_147_483_647, + )); + } return $calendar; } diff --git a/packages/datetime/tests/DateTimeTest.php b/packages/datetime/tests/DateTimeTest.php index 056b6f18bc..51d77bb0d5 100644 --- a/packages/datetime/tests/DateTimeTest.php +++ b/packages/datetime/tests/DateTimeTest.php @@ -7,10 +7,12 @@ use DateTimeImmutable; use DateTimeZone; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Tempest\DateTime\DateStyle; use Tempest\DateTime\DateTime; +use Tempest\DateTime\Exception\OverflowException; use Tempest\DateTime\Exception\UnexpectedValueException; use Tempest\DateTime\FormatPattern; use Tempest\DateTime\Meridiem; @@ -1031,4 +1033,36 @@ public function test_timezone_considerations_for_same_methods(): void $this->assertTrue($utc->isSameYear($brussels)); $this->assertTrue($utc->isSameMonth($brussels)); } + + #[Test] + public function from_parts_with_year_exceeding_intl_calendar_range_throws_overflow(): void + { + $this->expectException(OverflowException::class); + + DateTime::fromParts(Timezone::UTC, PHP_INT_MAX, 1, 1); + } + + #[Test] + public function from_parts_with_year_where_calendar_returns_false_throws_overflow(): void + { + $this->expectException(OverflowException::class); + + DateTime::fromParts(Timezone::UTC, 5_368_710, 1, 1); + } + + #[Test] + public function plus_month_on_extreme_year_throws_overflow(): void + { + $this->expectException(OverflowException::class); + + DateTime::fromParts(Timezone::UTC, 5_368_709, 12, 28)->plusMonth(); + } + + #[Test] + public function plus_year_on_extreme_year_throws_overflow(): void + { + $this->expectException(OverflowException::class); + + DateTime::fromParts(Timezone::UTC, 5_368_709, 6, 15)->plusYear(); + } } From d6e4aa8cf6af1398034adfe4f43eb43613a10077 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:34:11 +0100 Subject: [PATCH 032/134] chore(phpstan): update baseline for datetime runtime guards --- .../src/TemporalConvenienceMethods.php | 1 - phpstan-baseline.neon | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/datetime/src/TemporalConvenienceMethods.php b/packages/datetime/src/TemporalConvenienceMethods.php index d3c2362737..e024805030 100644 --- a/packages/datetime/src/TemporalConvenienceMethods.php +++ b/packages/datetime/src/TemporalConvenienceMethods.php @@ -558,7 +558,6 @@ public function __toString(): string /** * Stops the execution and dumps the current state of this temporal object. * - * @phpstan-ignore disallowed.function * @mago-expect lint:no-debug-symbols */ public function dd(): never diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 351d6873a9..217cec5645 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -49,6 +49,41 @@ parameters: path: packages/console/src/Terminal/Terminal.php count: 3 # Intentional: exec() is required for TTY terminal operations (stty, tput) + - + identifier: disallowed.function + path: packages/datetime/src/TemporalConvenienceMethods.php + count: 2 + # Intentional: dd() is the purpose of TemporalInterface::dd() + - + identifier: identical.alwaysFalse + path: packages/datetime/src/DateTime.php + count: 6 + # Runtime guard: IntlCalendar::get() returns false at runtime for extreme year values + - + identifier: booleanOr.alwaysFalse + path: packages/datetime/src/DateTime.php + count: 5 + # Runtime guard: IntlCalendar::get() returns false at runtime for extreme year values + - + identifier: instanceof.alwaysTrue + path: packages/datetime/src/DateTime.php + count: 1 + # Runtime guard: DateTimeInterface::getTimezone() can return false at runtime + - + identifier: disallowed.function + path: packages/console/src/Testing/ConsoleTester.php + count: 1 + # Intentional: ld() is the purpose of ConsoleTester::dd() + - + identifier: disallowed.function + path: src/Tempest/Framework/Commands/ConfigShowCommand.php + count: 1 + # Intentional: lw() used for debug output when available + - + identifier: disallowed.function + path: tests/Integration/Console/Fixtures/LogDebugCommand.php + count: 1 + # Intentional: lw() is the purpose of this test fixture - identifier: function.alreadyNarrowedType path: packages/console/src/GenericConsole.php From 1ed06b0754b9ddf02b79057f7fd0a8a7923963db Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:41:45 +0100 Subject: [PATCH 033/134] refactor(debug): remove unused Highlighter dependency --- packages/debug/src/TailDebugCommand.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/debug/src/TailDebugCommand.php b/packages/debug/src/TailDebugCommand.php index ae4338d78e..98397faa71 100644 --- a/packages/debug/src/TailDebugCommand.php +++ b/packages/debug/src/TailDebugCommand.php @@ -7,8 +7,6 @@ use Tempest\Console\Console; use Tempest\Console\ConsoleCommand; use Tempest\Console\Output\TailReader; -use Tempest\Container\Tag; -use Tempest\Highlight\Highlighter; use Tempest\Support\Filesystem; final readonly class TailDebugCommand @@ -16,8 +14,6 @@ public function __construct( private Console $console, private DebugConfig $debugConfig, - #[Tag('console')] - private Highlighter $highlighter, ) {} #[ConsoleCommand('tail:debug', description: 'Tails the debug log', aliases: ['debug:tail'])] From 8f2a8ee6c504202d1e0c0ef0900ff37de8eb5346 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:41:50 +0100 Subject: [PATCH 034/134] fix(debug): remove incompatible assertion in StacktraceTest --- packages/debug/tests/StacktraceTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/debug/tests/StacktraceTest.php b/packages/debug/tests/StacktraceTest.php index 332a371eea..6edf46f7be 100644 --- a/packages/debug/tests/StacktraceTest.php +++ b/packages/debug/tests/StacktraceTest.php @@ -22,7 +22,6 @@ public function creates_stacktrace_from_throwable(): void $this->assertSame('Test exception', $stacktrace->message); $this->assertSame(RuntimeException::class, $stacktrace->exceptionClass); $this->assertNotEmpty($stacktrace->frames); - $this->assertContainsOnlyInstancesOf(Frame::class, $stacktrace->frames); } #[Test] From ef7c074cf99ecec3622f89f11ec820835c8e88fd Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:41:56 +0100 Subject: [PATCH 035/134] chore(phpstan): add debug to scan paths and baseline --- phpstan-baseline.neon | 10 ++++++++++ phpstan.neon.dist | 1 + 2 files changed, 11 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 217cec5645..6380320b8a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -25,6 +25,16 @@ parameters: path: packages/clock/src/MockClock.php count: 1 # Intentional: dd() is the purpose of MockClock::dd() + - + identifier: disallowed.function + path: packages/debug/src/DOMDebug.php + count: 1 + # Intentional: lw() is the purpose of DOMDebug::dump() + - + identifier: disallowed.function + path: packages/debug/src/functions.php + count: 2 + # Intentional: dd() delegates to ld() and dump() delegates to lw() - identifier: property.uninitializedReadonly paths: diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e7318653b4..c8a487e082 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -23,6 +23,7 @@ parameters: - packages/core - packages/cryptography - packages/datetime + - packages/debug - tests reportUnmatchedIgnoredErrors: true From 2529df68beb4fa09da9f79eda7a34c5598840d38 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:49:17 +0100 Subject: [PATCH 036/134] chore(phpstan): generalize Testing path exclusion in baseline --- phpstan-baseline.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6380320b8a..69d214b46f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -6,7 +6,7 @@ parameters: - src/Tempest/Framework/Testing/* - tests/Integration/**/*Test.php - packages/auth/src/OAuth/Testing/* - - packages/cache/src/Testing/* + - packages/**/src/Testing/* - packages/core/src/Exceptions/ExceptionTester.php - packages/**/tests/**/*.php - packages/**/tests/*.php From 7f356b968bf3800b479f5a579e47a15d16b4848c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:49:22 +0100 Subject: [PATCH 037/134] refactor(event-bus): decouple FakeEventBus from GenericEventBus --- packages/event-bus/src/Testing/EventBusTester.php | 4 +++- packages/event-bus/src/Testing/FakeEventBus.php | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/event-bus/src/Testing/EventBusTester.php b/packages/event-bus/src/Testing/EventBusTester.php index b1409ecf45..668e4651b0 100644 --- a/packages/event-bus/src/Testing/EventBusTester.php +++ b/packages/event-bus/src/Testing/EventBusTester.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Assert; use Tempest\Container\Container; use Tempest\EventBus\EventBus; +use Tempest\EventBus\EventBusConfig; use Tempest\Support\Str; final class EventBusTester @@ -24,7 +25,8 @@ public function __construct( public function recordEventDispatches(bool $preventHandling = false): self { $this->fakeEventBus = new FakeEventBus( - genericEventBus: $this->container->get(EventBus::class), + eventBus: $this->container->get(EventBus::class), + eventBusConfig: $this->container->get(EventBusConfig::class), preventHandling: $preventHandling, ); diff --git a/packages/event-bus/src/Testing/FakeEventBus.php b/packages/event-bus/src/Testing/FakeEventBus.php index 9003c08be0..dcc0962a52 100644 --- a/packages/event-bus/src/Testing/FakeEventBus.php +++ b/packages/event-bus/src/Testing/FakeEventBus.php @@ -5,7 +5,7 @@ use Closure; use Tempest\EventBus\CallableEventHandler; use Tempest\EventBus\EventBus; -use Tempest\EventBus\GenericEventBus; +use Tempest\EventBus\EventBusConfig; use UnitEnum; final class FakeEventBus implements EventBus @@ -15,11 +15,12 @@ final class FakeEventBus implements EventBus /** @var array> */ public array $handlers { - get => $this->genericEventBus->eventBusConfig->handlers; + get => $this->eventBusConfig->handlers; } public function __construct( - private(set) GenericEventBus $genericEventBus, + private(set) EventBus $eventBus, + private(set) EventBusConfig $eventBusConfig, public bool $preventHandling = true, ) {} @@ -28,12 +29,12 @@ public function dispatch(string|object $event): void $this->dispatched[] = $event; if ($this->preventHandling === false) { - $this->genericEventBus->dispatch($event); + $this->eventBus->dispatch($event); } } public function listen(Closure $handler, string|UnitEnum|null $event = null): void { - $this->genericEventBus->listen($handler, $event); + $this->eventBus->listen($handler, $event); } } From 73c2fabe11192ba9f7d5753bdb9eff911e613fbf Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:49:27 +0100 Subject: [PATCH 038/134] fix(event-bus): add missing type annotation in test --- packages/event-bus/tests/EventBusTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/event-bus/tests/EventBusTest.php b/packages/event-bus/tests/EventBusTest.php index 3f47529ba3..f667779a9a 100644 --- a/packages/event-bus/tests/EventBusTest.php +++ b/packages/event-bus/tests/EventBusTest.php @@ -209,6 +209,7 @@ public function test_closure_based_handlers_using_listen_method_and_enums(): voi $eventBus->dispatch(EventEnum::TWO); + /** @var bool $hasHappened */ $this->assertTrue($hasHappened); } } From 3db2f52591081e64577781b0e87c38f9a4816c3a Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 02:49:33 +0100 Subject: [PATCH 039/134] chore(phpstan): add discovery and event-bus to scan paths --- phpstan.neon.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c8a487e082..02f2d4d0af 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -24,6 +24,8 @@ parameters: - packages/cryptography - packages/datetime - packages/debug + - packages/discovery + - packages/event-bus - tests reportUnmatchedIgnoredErrors: true From 02a688ed740b1f7699a5b86e7ef8fec35405d87b Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:01:39 +0100 Subject: [PATCH 040/134] chore(phpstan): add generation to scan paths --- phpstan.neon.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 02f2d4d0af..819df7549b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,10 +22,12 @@ parameters: - packages/container - packages/core - packages/cryptography + #- packages/database - packages/datetime - packages/debug - packages/discovery - packages/event-bus + - packages/generation - tests reportUnmatchedIgnoredErrors: true From 102312425de124f8d84f4dbd37aa65ec48855ae3 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:01:46 +0100 Subject: [PATCH 041/134] fix(generation): remove unnecessary phpstan-ignore annotations --- packages/generation/src/Php/ClassManipulator.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/generation/src/Php/ClassManipulator.php b/packages/generation/src/Php/ClassManipulator.php index ba848b881e..b227a794f5 100644 --- a/packages/generation/src/Php/ClassManipulator.php +++ b/packages/generation/src/Php/ClassManipulator.php @@ -18,13 +18,10 @@ final class ClassManipulator public function __construct(string|ReflectionClass $source) { if (is_file($source)) { - /** @phpstan-ignore-next-line */ $this->classType = ClassType::fromCode(Filesystem\read_file($source)); } elseif (is_string($source)) { - /** @phpstan-ignore-next-line */ $this->classType = ClassType::from($source, withBodies: true); } else { - /** @phpstan-ignore-next-line */ $this->classType = ClassType::from($source->getName(), withBodies: true); } From cc7aeab45242d6cf957d2fd38f7ee7bae53c45ab Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:02:01 +0100 Subject: [PATCH 042/134] fix(generation): correct type annotations in TypeScriptOutput --- packages/generation/src/TypeScript/TypeScriptOutput.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/generation/src/TypeScript/TypeScriptOutput.php b/packages/generation/src/TypeScript/TypeScriptOutput.php index 2c80408807..75ee3a1a9e 100644 --- a/packages/generation/src/TypeScript/TypeScriptOutput.php +++ b/packages/generation/src/TypeScript/TypeScriptOutput.php @@ -10,7 +10,7 @@ final readonly class TypeScriptOutput { /** - * @param array $namespaces Type definitions grouped by namespace + * @param array> $namespaces Type definitions grouped by namespace * @param array $imports Additional import statements to include */ public function __construct( @@ -21,13 +21,13 @@ public function __construct( /** * Gets all type definitions across all namespaces. * - * @return TypeDefinition[] + * @return array */ public function getAllDefinitions(): array { $definitions = []; - foreach ($this->namespaces as $namespace => $namespaceDefinitions) { + foreach ($this->namespaces as $namespaceDefinitions) { $definitions = [...$definitions, ...$namespaceDefinitions]; } @@ -47,7 +47,7 @@ public function getNamespaces(): array /** * Gets definitions for a specific namespace. * - * @return TypeDefinition[] + * @return array */ public function getDefinitionsForNamespace(string $namespace): array { From 6b7b7803860843bef33d03cd8f7716b4ced459a8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:01:54 +0100 Subject: [PATCH 043/134] fix(generation): add map for scalar types --- .../TypeResolvers/ScalarTypeResolver.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php index 73765ef03a..ae88f7df35 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php @@ -13,17 +13,22 @@ #[Priority(Priority::LOW)] final class ScalarTypeResolver implements TypeResolver { + private const array SCALAR_TYPE_MAP = [ + 'string' => 'string', + 'int' => 'number', + 'float' => 'number', + 'bool' => 'boolean', + ]; + public function canResolve(TypeReflector $type): bool { - return $type->isBuiltIn() && in_array($type->getName(), ['string', 'int', 'float', 'bool'], strict: true); + return $type->isBuiltIn() && isset(self::SCALAR_TYPE_MAP[$type->getName()]); } public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType { - return new ResolvedType(match ($type->getName()) { - 'string' => 'string', - 'int', 'float' => 'number', - 'bool' => 'boolean', - }); + $type = self::SCALAR_TYPE_MAP[$type->getName()] ?? throw new \LogicException(sprintf('Unsupported scalar type "%s".', $type->getName())); + + return new ResolvedType($type); } } From b078b59e59ad5dc55064756a8cd54945d05c3a65 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:17:53 +0100 Subject: [PATCH 044/134] chore(phpstan): add http to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 819df7549b..fffd005625 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -28,6 +28,7 @@ parameters: - packages/discovery - packages/event-bus - packages/generation + - packages/http - tests reportUnmatchedIgnoredErrors: true From 3ef3bfb9f4be519f9c58d608be34f98fcd80de6c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:17:58 +0100 Subject: [PATCH 045/134] fix(http): correct type annotation in IsRequest::accepts --- packages/http/src/IsRequest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http/src/IsRequest.php b/packages/http/src/IsRequest.php index 1ee0020fb6..53408abb37 100644 --- a/packages/http/src/IsRequest.php +++ b/packages/http/src/IsRequest.php @@ -140,7 +140,7 @@ public function accepts(ContentType ...$contentTypes): bool { $header = $this->headers->get(name: 'accept') ?? ''; - /** @var array{mediaType:string,subType:string} */ + /** @var list */ $acceptedMediaTypes = []; foreach (str($header)->explode(separator: ',') as $acceptedType) { From c4bc9ca3298b1f844f440aaea1076b10c227114c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:18:04 +0100 Subject: [PATCH 046/134] fix(http): fix Cookie phpdoc, nullability, and parsing --- packages/http/src/Cookie/Cookie.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/http/src/Cookie/Cookie.php b/packages/http/src/Cookie/Cookie.php index 71ff00fd6c..36f235faa7 100644 --- a/packages/http/src/Cookie/Cookie.php +++ b/packages/http/src/Cookie/Cookie.php @@ -17,8 +17,8 @@ final class Cookie implements Stringable * @param null|int $maxAge The maximum age of the cookie in seconds. This is an alternative to the expiration date. If set, the cookie will expire after the specified number of seconds. * @param null|string $domain This specifies the domain for which the cookie is valid. If set, the cookie will be sent to this domain and its subdomains. If `⁠null`, it defaults to the current domain. * @param null|string $path The URL path that must exist in the requested URL for the cookie to be sent. If set, the cookie will only be sent for requests to this path and its subdirectories. - * @param null|bool $secure When `true`, this cookie is only transmitted over secure connections. - * @param null|bool $httpOnly When `true`, this cookie will not be accessible using JavaScript. + * @param bool $secure When `true`, this cookie is only transmitted over secure connections. + * @param bool $httpOnly When `true`, this cookie will not be accessible using JavaScript. * @param null|SameSite $sameSite See {@see \Tempest\Http\Cookie\SameSite}. */ public function __construct( @@ -30,7 +30,7 @@ public function __construct( public ?string $path = '/', public bool $secure = true, public bool $httpOnly = false, - public SameSite $sameSite = SameSite::LAX, + public ?SameSite $sameSite = SameSite::LAX, ) {} public function withValue(string $value): self @@ -116,6 +116,7 @@ public static function createFromString(string $string): self } if ($attributeName === 'max-age') { + $cookie['max-age'] = (int) $attributeValue; $cookie['expires'] = time() + (int) $attributeValue; } } From 6a529e60e988e1d1791bffc59a8cb0b5fdefe135 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:18:09 +0100 Subject: [PATCH 047/134] refactor(http): use FileSessionConfig in FileSessionManager --- packages/http/src/Session/Managers/FileSessionManager.php | 4 ++-- .../Auth/Authentication/SessionAuthenticatorTest.php | 3 +-- tests/Integration/Http/FileSessionTest.php | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/http/src/Session/Managers/FileSessionManager.php b/packages/http/src/Session/Managers/FileSessionManager.php index af332393a6..9d4eac9866 100644 --- a/packages/http/src/Session/Managers/FileSessionManager.php +++ b/packages/http/src/Session/Managers/FileSessionManager.php @@ -5,8 +5,8 @@ namespace Tempest\Http\Session\Managers; use Tempest\Clock\Clock; +use Tempest\Http\Session\Config\FileSessionConfig; use Tempest\Http\Session\Session; -use Tempest\Http\Session\SessionConfig; use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; @@ -21,7 +21,7 @@ { public function __construct( private Clock $clock, - private SessionConfig $sessionConfig, + private FileSessionConfig $sessionConfig, ) {} public function getOrCreate(SessionId $id): Session diff --git a/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php b/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php index e20251574e..c23d41d08c 100644 --- a/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php +++ b/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php @@ -23,7 +23,6 @@ use Tempest\Http\Session\Config\FileSessionConfig; use Tempest\Http\Session\Managers\FileSessionManager; use Tempest\Http\Session\Session; -use Tempest\Http\Session\SessionConfig; use Tempest\Http\Session\SessionManager; use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -47,7 +46,7 @@ protected function configure(): void $this->container->config(new AuthConfig(authenticatables: [User::class])); $this->container->singleton(SessionManager::class, fn () => new FileSessionManager( $this->container->get(Clock::class), - $this->container->get(SessionConfig::class), + $this->container->get(FileSessionConfig::class), )); $this->database->migrate(CreateMigrationsTable::class, CreateUsersTableMigration::class, CreateApiKeysTableMigration::class); diff --git a/tests/Integration/Http/FileSessionTest.php b/tests/Integration/Http/FileSessionTest.php index 8fef58cdf0..eb87ddb835 100644 --- a/tests/Integration/Http/FileSessionTest.php +++ b/tests/Integration/Http/FileSessionTest.php @@ -13,7 +13,6 @@ use Tempest\Http\Session\Config\FileSessionConfig; use Tempest\Http\Session\Managers\FileSessionManager; use Tempest\Http\Session\Session; -use Tempest\Http\Session\SessionConfig; use Tempest\Http\Session\SessionCreated; use Tempest\Http\Session\SessionDeleted; use Tempest\Http\Session\SessionId; @@ -52,7 +51,7 @@ protected function configure(): void $this->container->singleton(SessionManager::class, fn () => new FileSessionManager( $this->container->get(Clock::class), - $this->container->get(SessionConfig::class), + $this->container->get(FileSessionConfig::class), )); } From cb2b0868333996f68bf7a3ba3aed7c518a248283 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:23:14 +0100 Subject: [PATCH 048/134] chore(http): add todo This would be a breaking change. --- packages/http/src/Session/Managers/FileSessionManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http/src/Session/Managers/FileSessionManager.php b/packages/http/src/Session/Managers/FileSessionManager.php index 9d4eac9866..6cfe992ed2 100644 --- a/packages/http/src/Session/Managers/FileSessionManager.php +++ b/packages/http/src/Session/Managers/FileSessionManager.php @@ -21,7 +21,7 @@ { public function __construct( private Clock $clock, - private FileSessionConfig $sessionConfig, + private FileSessionConfig $sessionConfig, // TODO: rename to $config, see RedisSessionManager and DatabaseSessionManager ) {} public function getOrCreate(SessionId $id): Session From 03741344490f4042a1877728086e73c1fa70c9a9 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:32:20 +0100 Subject: [PATCH 049/134] chore(phpstan): add http-client, icon, and kv-store to scan paths --- phpstan.neon.dist | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fffd005625..8316618354 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,13 +22,17 @@ parameters: - packages/container - packages/core - packages/cryptography - #- packages/database + #- packages/database - 342 errors - packages/datetime - packages/debug - packages/discovery - packages/event-bus - packages/generation - packages/http + - packages/http-client + - packages/icon + #- packages/intl - 53 errors + - packages/kv-store - tests reportUnmatchedIgnoredErrors: true From 59ee2e05152dea3c31565858bf82e1433e6f5e73 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:32:27 +0100 Subject: [PATCH 050/134] fix(icon): add type annotation for cache failure check --- packages/icon/src/Icon.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/icon/src/Icon.php b/packages/icon/src/Icon.php index c97a0b3c37..8efcbf076b 100644 --- a/packages/icon/src/Icon.php +++ b/packages/icon/src/Icon.php @@ -40,7 +40,10 @@ public function render(string $icon): ?string expiresAt: $this->iconConfig->expiresAfter, ); - if ($this->iconCache->get("icon-failure-{$collection}-{$iconName}")) { + /** @var bool $failed */ + $failed = $this->iconCache->get("icon-failure-{$collection}-{$iconName}"); + + if ($failed) { $this->iconCache->delete("icon-{$collection}-{$iconName}"); } From cae26e40f6680a94ed16ab538838166a5d973beb Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:32:32 +0100 Subject: [PATCH 051/134] fix(kv-store): add default arm to RedisExtensionWasMissing match --- packages/kv-store/src/Redis/RedisExtensionWasMissing.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kv-store/src/Redis/RedisExtensionWasMissing.php b/packages/kv-store/src/Redis/RedisExtensionWasMissing.php index 0e595127bb..19a05154ae 100644 --- a/packages/kv-store/src/Redis/RedisExtensionWasMissing.php +++ b/packages/kv-store/src/Redis/RedisExtensionWasMissing.php @@ -13,6 +13,7 @@ public function __construct(string $fqcn) 'Redis client not found.' . match ($fqcn) { \Redis::class => ' You may be missing the `redis` extension.', Predis\Client::class => ' You may need to install the `predis/predis` package.', + default => ' Install the `redis` extension or the `predis/predis` package.', }, ); } From 56e1aa0486489dfcd5d09da490b440a8c98b0bbe Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:32:38 +0100 Subject: [PATCH 052/134] fix(kv-store): remove unnecessary array_filter in PredisClientTest --- packages/kv-store/tests/PredisClientTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kv-store/tests/PredisClientTest.php b/packages/kv-store/tests/PredisClientTest.php index d6f11def13..72ee2fa352 100644 --- a/packages/kv-store/tests/PredisClientTest.php +++ b/packages/kv-store/tests/PredisClientTest.php @@ -22,13 +22,13 @@ protected function configure(): void $this->redis = new PredisClient( client: new Predis\Client( - parameters: array_filter([ + parameters: [ 'scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => 6379, 'database' => 6, 'timeout' => .2, - ]), + ], options: ['prefix' => 'tempest_test:'], ), ); From 8f56dd9f265c7f75e9423a7d6149342387dbf873 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:32:43 +0100 Subject: [PATCH 053/134] feat(kv-store): add persistentId option to RedisConfig --- packages/kv-store/src/Redis/Config/RedisConfig.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kv-store/src/Redis/Config/RedisConfig.php b/packages/kv-store/src/Redis/Config/RedisConfig.php index ec63e7759c..5356b67fec 100644 --- a/packages/kv-store/src/Redis/Config/RedisConfig.php +++ b/packages/kv-store/src/Redis/Config/RedisConfig.php @@ -50,6 +50,11 @@ public function __construct( */ public bool $persistent = false, + /** + * Identity for the requested persistent connection. Useful when multiple persistent connections are needed. + */ + public ?string $persistentId = null, + /** * The maximum duration, in seconds, to wait for a connection to be established. */ From 0d17e7074425712241aba24645741ce22afb4000 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:46:08 +0100 Subject: [PATCH 054/134] fix(log): add missing LogChannel import to config PHPDocs --- packages/log/src/Config/DailyLogConfig.php | 1 + packages/log/src/Config/SimpleLogConfig.php | 1 + packages/log/src/Config/SysLogConfig.php | 1 + packages/log/src/Config/WeeklyLogConfig.php | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/log/src/Config/DailyLogConfig.php b/packages/log/src/Config/DailyLogConfig.php index 281c7ef383..e6df856ac0 100644 --- a/packages/log/src/Config/DailyLogConfig.php +++ b/packages/log/src/Config/DailyLogConfig.php @@ -3,6 +3,7 @@ namespace Tempest\Log\Config; use Tempest\Log\Channels\DailyLogChannel; +use Tempest\Log\LogChannel; use Tempest\Log\LogConfig; use Tempest\Log\LogLevel; use UnitEnum; diff --git a/packages/log/src/Config/SimpleLogConfig.php b/packages/log/src/Config/SimpleLogConfig.php index 3d12dfcbad..ce9ceae012 100644 --- a/packages/log/src/Config/SimpleLogConfig.php +++ b/packages/log/src/Config/SimpleLogConfig.php @@ -3,6 +3,7 @@ namespace Tempest\Log\Config; use Tempest\Log\Channels\AppendLogChannel; +use Tempest\Log\LogChannel; use Tempest\Log\LogConfig; use Tempest\Log\LogLevel; use UnitEnum; diff --git a/packages/log/src/Config/SysLogConfig.php b/packages/log/src/Config/SysLogConfig.php index e1a72895f1..09bca80756 100644 --- a/packages/log/src/Config/SysLogConfig.php +++ b/packages/log/src/Config/SysLogConfig.php @@ -3,6 +3,7 @@ namespace Tempest\Log\Config; use Tempest\Log\Channels\SysLogChannel; +use Tempest\Log\LogChannel; use Tempest\Log\LogConfig; use Tempest\Log\LogLevel; use UnitEnum; diff --git a/packages/log/src/Config/WeeklyLogConfig.php b/packages/log/src/Config/WeeklyLogConfig.php index 3b57e3e077..93b87e0bf2 100644 --- a/packages/log/src/Config/WeeklyLogConfig.php +++ b/packages/log/src/Config/WeeklyLogConfig.php @@ -3,6 +3,7 @@ namespace Tempest\Log\Config; use Tempest\Log\Channels\WeeklyLogChannel; +use Tempest\Log\LogChannel; use Tempest\Log\LogConfig; use Tempest\Log\LogLevel; use UnitEnum; From fd7caacd14fe139c41f5a5c07928660b9e2b9463 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:46:14 +0100 Subject: [PATCH 055/134] fix(log): add missing $channels property to SlackLogConfig --- packages/log/src/Config/SlackLogConfig.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/log/src/Config/SlackLogConfig.php b/packages/log/src/Config/SlackLogConfig.php index b4f471d6fc..c82fcef597 100644 --- a/packages/log/src/Config/SlackLogConfig.php +++ b/packages/log/src/Config/SlackLogConfig.php @@ -4,6 +4,7 @@ use Tempest\Log\Channels\Slack\PresentationMode; use Tempest\Log\Channels\SlackLogChannel; +use Tempest\Log\LogChannel; use Tempest\Log\LogConfig; use Tempest\Log\LogLevel; use UnitEnum; @@ -31,6 +32,7 @@ final class SlackLogConfig implements LogConfig * @param string|null $username The username to display as the sender of the message. * @param PresentationMode $mode The display mode for the Slack messages. * @param LogLevel $minimumLogLevel The minimum log level to record. + * @param array $channels Additional channels to include in the configuration. * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used. * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration. */ @@ -39,7 +41,8 @@ public function __construct( private(set) ?string $channelId = null, private(set) ?string $username = null, private(set) PresentationMode $mode = PresentationMode::INLINE, - private LogLevel $minimumLogLevel = LogLevel::DEBUG, + private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG, + private(set) array $channels = [], private(set) ?string $prefix = null, private(set) null|UnitEnum|string $tag = null, ) {} From 2f1f84ed841dd9b885926d195c7875e27fc673e4 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:46:19 +0100 Subject: [PATCH 056/134] fix(log): remove dead null coalesce on non-nullable $maxFiles --- packages/log/src/Channels/DailyLogChannel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/log/src/Channels/DailyLogChannel.php b/packages/log/src/Channels/DailyLogChannel.php index 938c165760..c6e6c71d26 100644 --- a/packages/log/src/Channels/DailyLogChannel.php +++ b/packages/log/src/Channels/DailyLogChannel.php @@ -39,7 +39,7 @@ public function getHandlers(Level $level): array return [ new RotatingFileHandler( filename: $this->path, - maxFiles: $this->maxFiles ?? 0, + maxFiles: $this->maxFiles, level: $level, bubble: $this->bubble, filePermission: $this->filePermission, From ce055722a39af9a2c4158313c769962ca83d3711 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 03:46:25 +0100 Subject: [PATCH 057/134] chore(phpstan): add log to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8316618354..03d18b3d39 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -33,6 +33,7 @@ parameters: - packages/icon #- packages/intl - 53 errors - packages/kv-store + - packages/log - tests reportUnmatchedIgnoredErrors: true From 1801351bf2b7d97acce634774837e45563d96a9f Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:03:10 +0100 Subject: [PATCH 058/134] chore(phpstan): add mail to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 03d18b3d39..1b010590c3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -34,6 +34,7 @@ parameters: #- packages/intl - 53 errors - packages/kv-store - packages/log + - packages/mail - tests reportUnmatchedIgnoredErrors: true From 09385101d89a714f740647723fd60a4918e2f04b Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:03:13 +0100 Subject: [PATCH 059/134] fix(mail): correct PHPDoc type for MailerConfig::$transport --- packages/mail/src/MailerConfig.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/mail/src/MailerConfig.php b/packages/mail/src/MailerConfig.php index e43da66b43..4cbfbb618e 100644 --- a/packages/mail/src/MailerConfig.php +++ b/packages/mail/src/MailerConfig.php @@ -2,7 +2,6 @@ namespace Tempest\Mail; -use Symfony\Component\Mailer\Transport; use Symfony\Component\Mailer\Transport\TransportInterface; interface MailerConfig @@ -10,7 +9,7 @@ interface MailerConfig /** * The underlying Symfony transport class. * - * @param class-string + * @var class-string */ public string $transport { get; From 243e8d24187da1862babd9f5ad0bb3d07552d9ba Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:03:16 +0100 Subject: [PATCH 060/134] fix(mail): add Attachment[] type to GenericEmail --- packages/mail/src/GenericEmail.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mail/src/GenericEmail.php b/packages/mail/src/GenericEmail.php index f9ff225c74..594db08ac5 100644 --- a/packages/mail/src/GenericEmail.php +++ b/packages/mail/src/GenericEmail.php @@ -30,6 +30,7 @@ public function __construct( public null|string|array|EmailAddress $replyTo = null, public array $headers = [], public EmailPriority $priority = EmailPriority::NORMAL, + /** @var Attachment[] */ public array $attachments = [], ) {} } From a6c3d91dabaab50d612f290388f706f541f4bc9f Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:03:17 +0100 Subject: [PATCH 061/134] fix(mail): remove nullsafe call on non-nullable EventBus --- packages/mail/src/GenericMailer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mail/src/GenericMailer.php b/packages/mail/src/GenericMailer.php index bbe39026c6..efe1631c84 100644 --- a/packages/mail/src/GenericMailer.php +++ b/packages/mail/src/GenericMailer.php @@ -25,6 +25,6 @@ public function send(Email $email): void $this->transport->send($symfonyMail); - $this->eventBus?->dispatch(new EmailWasSent($email)); + $this->eventBus->dispatch(new EmailWasSent($email)); } } From 75d54277374185d933a13408cd0f94bac6d32757 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:03:31 +0100 Subject: [PATCH 062/134] fix(mail): remove dead code in MailTester --- packages/mail/src/Testing/MailTester.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/mail/src/Testing/MailTester.php b/packages/mail/src/Testing/MailTester.php index c534347ab1..c1c0a58e4c 100644 --- a/packages/mail/src/Testing/MailTester.php +++ b/packages/mail/src/Testing/MailTester.php @@ -21,7 +21,6 @@ final class MailTester { - private ?Email $sentEmail = null; private ?SymfonyEmail $sentSymfonyEmail = null; public function __construct( @@ -32,7 +31,6 @@ public function send(Email $email): self { $this->mailer->send($email); - $this->sentEmail = $email; $this->sentSymfonyEmail = map($email)->with(EmailToSymfonyEmailMapper::class)->do(); return $this; @@ -385,8 +383,6 @@ public function assertAttached(string $filename, ?Closure $callback = null): sel $filename, Arr\join(Arr\map($attachments, fn (DataPart $attachment) => $attachment->getName())), )); - - return $this; } /** From b1855d362023f88c587b8b179f4f018389db8514 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:03:35 +0100 Subject: [PATCH 063/134] fix(mail): collapse redundant match arms in address helpers --- packages/mail/src/EmailToSymfonyEmailMapper.php | 3 +-- packages/mail/src/Testing/MailTester.php | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/mail/src/EmailToSymfonyEmailMapper.php b/packages/mail/src/EmailToSymfonyEmailMapper.php index 596df7703c..d6f862b62d 100644 --- a/packages/mail/src/EmailToSymfonyEmailMapper.php +++ b/packages/mail/src/EmailToSymfonyEmailMapper.php @@ -122,8 +122,7 @@ private function convertAddresses(null|string|array|EmailAddress $addresses): ar ->map(fn (string|EmailAddress|SymfonyAddress $address) => match (true) { $address instanceof SymfonyAddress => $address, $address instanceof EmailAddress => new SymfonyAddress($address->email, $address->name ?? ''), - is_string($address) => SymfonyAddress::create($address), - default => null, + default => SymfonyAddress::create($address), }) ->filter() ->toArray(); diff --git a/packages/mail/src/Testing/MailTester.php b/packages/mail/src/Testing/MailTester.php index c1c0a58e4c..1d19951bcf 100644 --- a/packages/mail/src/Testing/MailTester.php +++ b/packages/mail/src/Testing/MailTester.php @@ -448,8 +448,7 @@ private function convertAddresses(null|string|array|EmailAddress $addresses): ar return match (true) { $address instanceof SymfonyAddress => $address->getAddress(), $address instanceof EmailAddress => $address->email, - is_string($address) => $address, - default => null, + default => $address, }; }) ->filter() From 2aedd4c44e6a15fa00320d6e56d8bb38f8cab91a Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:03:39 +0100 Subject: [PATCH 064/134] fix(mail): add default arm to SMTP scheme match --- packages/mail/src/mail.config.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mail/src/mail.config.php b/packages/mail/src/mail.config.php index 2865a4b73a..4d74e68d79 100644 --- a/packages/mail/src/mail.config.php +++ b/packages/mail/src/mail.config.php @@ -15,10 +15,13 @@ ); } +$scheme = strtolower(env('MAIL_SMTP_SCHEME', default: 'smtp')); + return new SmtpMailerConfig( - scheme: match (strtolower(env('MAIL_SMTP_SCHEME', default: 'smtp'))) { + scheme: match ($scheme) { 'smtps' => SmtpScheme::SMTPS, 'smtp' => SmtpScheme::SMTP, + default => throw new InvalidArgumentException(sprintf('Unsupported SMTP scheme "%s". Supported schemes: smtp, smtps.', $scheme)), }, host: env('MAIL_SMTP_HOST', default: '127.0.0.1'), port: env('MAIL_SMTP_PORT', default: 2525), From d1417860f9c016f42c11fa9b6288230dc5fbd1d9 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:20:02 +0100 Subject: [PATCH 065/134] chore(phpstan): add mapper to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1b010590c3..4df5704358 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -35,6 +35,7 @@ parameters: - packages/kv-store - packages/log - packages/mail + - packages/mapper - tests reportUnmatchedIgnoredErrors: true From 3fcc0aea3b7d03dfc1a319351eb11930e83d460f Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:20:13 +0100 Subject: [PATCH 066/134] fix(mapper): remove unused closure variable --- packages/mapper/src/Mappers/ArrayToObjectMapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mapper/src/Mappers/ArrayToObjectMapper.php b/packages/mapper/src/Mappers/ArrayToObjectMapper.php index 7694cfee9d..78c7ef7a37 100644 --- a/packages/mapper/src/Mappers/ArrayToObjectMapper.php +++ b/packages/mapper/src/Mappers/ArrayToObjectMapper.php @@ -161,7 +161,7 @@ private function setChildParentRelation(object $parent, mixed $child, ClassRefle public function resolveValue(PropertyReflector $property, mixed $value): mixed { - $caster = $this->memoize((string) $property, function () use ($property, $value) { + $caster = $this->memoize((string) $property, function () use ($property) { return $this->casterFactory ->in($this->context) ->forProperty($property); From 6e915bf1366dee4d808a35d49b954db0e47be529 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:20:16 +0100 Subject: [PATCH 067/134] fix(mapper): correct @var annotation in ObjectFactory --- packages/mapper/src/ObjectFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mapper/src/ObjectFactory.php b/packages/mapper/src/ObjectFactory.php index 22207e64d9..b0f71f5d62 100644 --- a/packages/mapper/src/ObjectFactory.php +++ b/packages/mapper/src/ObjectFactory.php @@ -374,12 +374,12 @@ private function mapWith(mixed $mapper, mixed $from, mixed $to): mixed */ private function resolveMappers(): array { + /** @var Mapper[] $mappers */ $mappers = []; $context = MappingContext::from($this->context); foreach ($this->config->mappers as $mapperClass) { - /** @var Mapper $mapper */ $mappers[] = $this->container->get($mapperClass, context: $context); } From 6d3cf07fa78f058019741fa339d5bc7415a248ba Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:20:19 +0100 Subject: [PATCH 068/134] fix(mapper): fix return type and PHPDoc in SerializerFactory --- packages/mapper/src/SerializerFactory.php | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/mapper/src/SerializerFactory.php b/packages/mapper/src/SerializerFactory.php index 336d3f65a9..5917fd6d09 100644 --- a/packages/mapper/src/SerializerFactory.php +++ b/packages/mapper/src/SerializerFactory.php @@ -4,7 +4,6 @@ namespace Tempest\Mapper; -use Closure; use Tempest\Container\Container; use Tempest\Container\Singleton; use Tempest\Reflection\ClassReflector; @@ -83,11 +82,7 @@ public function forProperty(PropertyReflector $property): ?Serializer } } - $serializer = $this->resolveSerializer($serializerClass, $property); - - if ($serializer !== null) { - return $serializer; - } + return $this->resolveSerializer($serializerClass, $property); } return null; @@ -113,20 +108,16 @@ public function forValue(mixed $value): ?Serializer } } - $serializer = $this->resolveSerializer($serializerClass, $input); - - if ($serializer !== null) { - return $serializer; - } + return $this->resolveSerializer($serializerClass, $input); } return null; } /** - * @param Closure|class-string $serializerClass + * @param class-string $serializerClass */ - private function resolveSerializer(string $serializerClass, PropertyReflector|TypeReflector|string $input): ?Serializer + private function resolveSerializer(string $serializerClass, PropertyReflector|TypeReflector|string $input): Serializer { $context = MappingContext::from($this->context); @@ -138,7 +129,7 @@ private function resolveSerializer(string $serializerClass, PropertyReflector|Ty } /** - * @return array{class-string|Closure,int}[] + * @return array{class-string,int}[] */ private function resolveSerializers(): array { From a5141f2ccfeb9edbc183abffcaeb1d987f425ba5 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:46:36 +0100 Subject: [PATCH 069/134] fix(reflection): narrow EnumReflector and TypeReflector types --- packages/reflection/src/EnumReflector.php | 6 +++--- packages/reflection/src/TypeReflector.php | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/reflection/src/EnumReflector.php b/packages/reflection/src/EnumReflector.php index c5fdd35395..f1d204b569 100644 --- a/packages/reflection/src/EnumReflector.php +++ b/packages/reflection/src/EnumReflector.php @@ -21,15 +21,15 @@ final class EnumReflector implements Reflector private readonly PHPReflectionEnum $reflectionEnum; /** - * @param class-string|TEnumName|PHPReflectionEnum $reflectionEnum + * @param class-string|TEnumName|self|PHPReflectionEnum $reflectionEnum */ - public function __construct(string|object $reflectionEnum) + public function __construct(string|UnitEnum|self|PHPReflectionEnum $reflectionEnum) { if (is_string($reflectionEnum)) { $reflectionEnum = new PHPReflectionEnum($reflectionEnum); } elseif ($reflectionEnum instanceof self) { $reflectionEnum = $reflectionEnum->getReflection(); - } elseif (! $reflectionEnum instanceof PHPReflectionEnum) { + } elseif ($reflectionEnum instanceof UnitEnum) { $reflectionEnum = new PHPReflectionEnum($reflectionEnum); } diff --git a/packages/reflection/src/TypeReflector.php b/packages/reflection/src/TypeReflector.php index f208b3aa6a..739cfd4e58 100644 --- a/packages/reflection/src/TypeReflector.php +++ b/packages/reflection/src/TypeReflector.php @@ -171,6 +171,10 @@ public function isEnumCase(): bool public function asEnumCase(): PHPReflectionEnumUnitCase { + if (! $this->reflector instanceof PHPReflectionEnumUnitCase) { + throw new \LogicException(sprintf('Cannot get enum case from `%s`.', $this->definition)); + } + return $this->reflector; } From 7fa31f48ac1ad4a04b70597cd5faafcf4a345bee Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:47:14 +0100 Subject: [PATCH 070/134] test(reflection): add EnumReflector constructor tests --- .../reflection/tests/EnumReflectorTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/reflection/tests/EnumReflectorTest.php b/packages/reflection/tests/EnumReflectorTest.php index 398da38d16..f3e386d3fa 100644 --- a/packages/reflection/tests/EnumReflectorTest.php +++ b/packages/reflection/tests/EnumReflectorTest.php @@ -197,13 +197,47 @@ public function constructor_with_enum_instance(): void $this->assertSame(TestUnitEnum::class, $reflector->getName()); } + #[Test] + public function constructor_with_backed_enum_instance(): void + { + $reflector = new EnumReflector(TestBackedEnum::ACTIVE); + + $this->assertSame(TestBackedEnum::class, $reflector->getName()); + $this->assertTrue($reflector->isBacked()); + $this->assertSame(TestBackedEnum::ACTIVE, $reflector->getCase('ACTIVE')); + } + + #[Test] + public function constructor_with_native_reflection_enum(): void + { + $reflectionEnum = new ReflectionEnum(TestBackedEnum::class); + $reflector = new EnumReflector($reflectionEnum); + + $this->assertSame(TestBackedEnum::class, $reflector->getName()); + $this->assertTrue($reflector->isBacked()); + $this->assertSame($reflectionEnum, $reflector->getReflection()); + } + #[Test] public function constructor_with_enum_reflector(): void + { + $reflector1 = new EnumReflector(TestBackedEnum::class); + $reflector2 = new EnumReflector($reflector1); + + $this->assertEquals($reflector1, $reflector2); + $this->assertTrue($reflector2->isBacked()); + $this->assertSame('string', $reflector2->getBackingType()?->getName()); + } + + #[Test] + public function constructor_with_unit_enum_reflector(): void { $reflector1 = new EnumReflector(TestUnitEnum::class); $reflector2 = new EnumReflector($reflector1); $this->assertEquals($reflector1, $reflector2); + $this->assertFalse($reflector2->isBacked()); + $this->assertNull($reflector2->getBackingType()); } #[Test] From 6f3eeef86bc0638f0e0c7278683f480e74d3ea1e Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:47:16 +0100 Subject: [PATCH 071/134] fix(reflection): make TestAttribute class final --- packages/reflection/tests/EnumReflectorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reflection/tests/EnumReflectorTest.php b/packages/reflection/tests/EnumReflectorTest.php index f3e386d3fa..2af7df7d43 100644 --- a/packages/reflection/tests/EnumReflectorTest.php +++ b/packages/reflection/tests/EnumReflectorTest.php @@ -281,7 +281,7 @@ enum TestEnumWithInterface: string implements TestInterface } #[\Attribute] -class TestAttribute +final class TestAttribute { public function __construct( public string $value, From 9524b2a3b94651526fd03dffde922609daea94a9 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:48:11 +0100 Subject: [PATCH 072/134] chore(phpstan): add reflection to scan paths --- phpstan.neon.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4df5704358..a7ee8f1cd0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -36,6 +36,8 @@ parameters: - packages/log - packages/mail - packages/mapper + #- packages/process - 31 errors + - packages/reflection - tests reportUnmatchedIgnoredErrors: true From c59c2c4aab873d9d89899fbfcb0cc1546b60a27b Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:54:21 +0100 Subject: [PATCH 073/134] fix(router): use native Throwable extension in ConvertsToResponse --- packages/router/src/Exceptions/ConvertsToResponse.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/router/src/Exceptions/ConvertsToResponse.php b/packages/router/src/Exceptions/ConvertsToResponse.php index adf912c1c7..f49d1258d4 100644 --- a/packages/router/src/Exceptions/ConvertsToResponse.php +++ b/packages/router/src/Exceptions/ConvertsToResponse.php @@ -6,10 +6,8 @@ /** * Marks this exception class as one that can be converted to a response. - * - * @phpstan-require-extends \Throwable */ -interface ConvertsToResponse +interface ConvertsToResponse extends \Throwable { /** * Gets a response to be sent to the client. From 579ea5101fa8e4b02b87450ca72b1165de6e8724 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:54:27 +0100 Subject: [PATCH 074/134] fix(router): guard TEMPEST_START constant in DevelopmentException --- .../router/src/Exceptions/DevelopmentException.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/router/src/Exceptions/DevelopmentException.php b/packages/router/src/Exceptions/DevelopmentException.php index c1d3608756..8ffa2d6da4 100644 --- a/packages/router/src/Exceptions/DevelopmentException.php +++ b/packages/router/src/Exceptions/DevelopmentException.php @@ -60,7 +60,7 @@ public function __construct(Throwable $throwable, Response $response, Request $r ], 'resources' => [ 'memoryPeakUsage' => memory_get_peak_usage(real_usage: true), - 'executionTimeMs' => (hrtime(as_number: true) - TEMPEST_START) / 1_000_000, + 'executionTimeMs' => $this->resolveExecutionTimeInMilliseconds(), ], 'versions' => [ 'php' => PHP_VERSION, @@ -71,6 +71,17 @@ public function __construct(Throwable $throwable, Response $response, Request $r ); } + private function resolveExecutionTimeInMilliseconds(): float + { + $tempestStart = defined('TEMPEST_START') ? constant('TEMPEST_START') : null; + + if (! is_int($tempestStart)) { + return 0.0; + } + + return (hrtime(as_number: true) - $tempestStart) / 1_000_000; + } + private function enhanceStacktraceForViewCompilation(ViewCompilationFailed $exception, Stacktrace $stacktrace): Stacktrace { $previous = $exception->getPrevious(); From 4f9c2efefbb7b91349666e3163f827be23c96ee0 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:54:32 +0100 Subject: [PATCH 075/134] fix(router): check container before resolving MatchedRoute --- packages/router/src/GenericRouter.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/router/src/GenericRouter.php b/packages/router/src/GenericRouter.php index 5c87319d2a..6bedfe0e0e 100644 --- a/packages/router/src/GenericRouter.php +++ b/packages/router/src/GenericRouter.php @@ -39,14 +39,12 @@ public function dispatch(Request|PsrRequest $request): Response private function getCallable(): HttpMiddlewareCallable { $callControllerAction = function (Request $_) { - $matchedRoute = $this->container->get(MatchedRoute::class); - - if ($matchedRoute === null) { - // At this point, the `MatchRouteMiddleware` should have run. - // If that's not the case, then someone messed up by clearing all HTTP middleware + if (! $this->container->has(MatchedRoute::class)) { throw new MatchedRouteCouldNotBeResolved(); } + $matchedRoute = $this->container->get(MatchedRoute::class); + $route = $matchedRoute->route; $response = $this->container->invoke( From 86d016eabfd6d63f19719ff2b4530e4d21816cd8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:54:39 +0100 Subject: [PATCH 076/134] fix(router): correct type annotations on without property --- packages/router/src/Routing/Construction/DiscoveredRoute.php | 3 ++- packages/router/tests/FakeRouteBuilder.php | 1 + packages/router/tests/FakeRouteBuilderWithOptionalParams.php | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/router/src/Routing/Construction/DiscoveredRoute.php b/packages/router/src/Routing/Construction/DiscoveredRoute.php index 4ea2d27e29..be603c8821 100644 --- a/packages/router/src/Routing/Construction/DiscoveredRoute.php +++ b/packages/router/src/Routing/Construction/DiscoveredRoute.php @@ -34,7 +34,7 @@ public static function fromRoute(Route $route, array $decorators, MethodReflecto optionalParameters: $uri['optional'], middleware: $route->middleware, handler: $methodReflector, - without: $route->without ?? [], + without: $route->without, ); } @@ -49,6 +49,7 @@ private function __construct( /** @var class-string<\Tempest\Router\HttpMiddleware>[] */ public array $middleware, public MethodReflector $handler, + /** @var class-string<\Tempest\Router\HttpMiddleware>[] */ public array $without = [], ) { $this->isDynamic = $parameters !== []; diff --git a/packages/router/tests/FakeRouteBuilder.php b/packages/router/tests/FakeRouteBuilder.php index 1897a68431..1d041c7996 100644 --- a/packages/router/tests/FakeRouteBuilder.php +++ b/packages/router/tests/FakeRouteBuilder.php @@ -21,6 +21,7 @@ public function __construct( public string $uri = '/', /** @var class-string[] */ public array $middleware = [], + /** @var class-string[] */ public array $without = [], ) { $this->handler = new MethodReflector(new ReflectionMethod($this, 'handler')); diff --git a/packages/router/tests/FakeRouteBuilderWithOptionalParams.php b/packages/router/tests/FakeRouteBuilderWithOptionalParams.php index ed3356634e..ed03e644e8 100644 --- a/packages/router/tests/FakeRouteBuilderWithOptionalParams.php +++ b/packages/router/tests/FakeRouteBuilderWithOptionalParams.php @@ -21,6 +21,7 @@ public function __construct( public string $uri = '/', /** @var class-string[] */ public array $middleware = [], + /** @var class-string[] */ public array $without = [], private string $handlerMethod = 'handler', ) { From 3d809555b4e7ea003fb3825bc31939c8a361201c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:54:45 +0100 Subject: [PATCH 077/134] fix(router): remove unused RouteConfig and nullsafe access --- packages/router/src/Static/StaticGenerateCommand.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/router/src/Static/StaticGenerateCommand.php b/packages/router/src/Static/StaticGenerateCommand.php index 4de293f5bf..ecef517c65 100644 --- a/packages/router/src/Static/StaticGenerateCommand.php +++ b/packages/router/src/Static/StaticGenerateCommand.php @@ -20,7 +20,6 @@ use Tempest\HttpClient\HttpClient; use Tempest\Intl; use Tempest\Router\DataProvider; -use Tempest\Router\RouteConfig; use Tempest\Router\Router; use Tempest\Router\Static\Exceptions\DeadLinksDetectedException; use Tempest\Router\Static\Exceptions\InvalidStatusCodeException; @@ -47,7 +46,6 @@ final class StaticGenerateCommand public function __construct( private readonly AppConfig $appConfig, - private readonly RouteConfig $routeConfig, private readonly Console $console, private readonly Kernel $kernel, private readonly Container $container, @@ -264,7 +262,7 @@ private function detectDeadLinks(string $uri, string $html, bool $checkExternal continue; } - if ($response?->status->isRedirect()) { + if ($response->status->isRedirect()) { $target = Arr\first($response->getHeader('Location')->values); } } while ($response?->status->isRedirect()); From 9cf1d002f4e02dc4fe8982ec2436d88ce47342da Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:54:50 +0100 Subject: [PATCH 078/134] fix(router): pass correct arity to controllerNotFound() --- packages/router/src/UriGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/UriGenerator.php b/packages/router/src/UriGenerator.php index 0a9bdcd710..5fa8630e33 100644 --- a/packages/router/src/UriGenerator.php +++ b/packages/router/src/UriGenerator.php @@ -216,7 +216,7 @@ private function normalizeActionToUri(array|string|MethodReflector $action): str if ($routes === []) { if (! class_exists($controllerClass)) { - throw ControllerActionDoesNotExist::controllerNotFound($controllerClass, $controllerMethod); + throw ControllerActionDoesNotExist::controllerNotFound($controllerClass); } if (! method_exists($controllerClass, $controllerMethod)) { From a749193f0d5812736fd1d9f82e2998e504898df8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:54:57 +0100 Subject: [PATCH 079/134] chore(phpstan): add router to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a7ee8f1cd0..60b75f8e1b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -38,6 +38,7 @@ parameters: - packages/mapper #- packages/process - 31 errors - packages/reflection + - packages/router - tests reportUnmatchedIgnoredErrors: true From 3f307debafbee492b240a2009aa06ef2ea629d6e Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:57:39 +0100 Subject: [PATCH 080/134] chore(phpstan): add storage to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 60b75f8e1b..2b08220a84 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -39,6 +39,7 @@ parameters: #- packages/process - 31 errors - packages/reflection - packages/router + - packages/storage - tests reportUnmatchedIgnoredErrors: true From 31aee6ff3910169c2ada376a9dad9c2e0cd490a6 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 04:57:45 +0100 Subject: [PATCH 081/134] fix(storage): update Azure adapter for new SDK API --- packages/storage/src/Config/AzureStorageConfig.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/storage/src/Config/AzureStorageConfig.php b/packages/storage/src/Config/AzureStorageConfig.php index b91ca14256..66f5055a04 100644 --- a/packages/storage/src/Config/AzureStorageConfig.php +++ b/packages/storage/src/Config/AzureStorageConfig.php @@ -3,8 +3,8 @@ namespace Tempest\Storage\Config; use AzureOss\FlysystemAzureBlobStorage\AzureBlobStorageAdapter; +use AzureOss\Storage\Blob\BlobServiceClient; use League\Flysystem\FilesystemAdapter; -use MicrosoftAzure\Storage\Blob\BlobRestProxy; use UnitEnum; final class AzureStorageConfig implements StorageConfig @@ -41,8 +41,7 @@ public function __construct( public function createAdapter(): FilesystemAdapter { return new AzureBlobStorageAdapter( - client: BlobRestProxy::createBlobService($this->dsn), - container: $this->container, + containerClient: BlobServiceClient::fromConnectionString($this->dsn)->getContainerClient($this->container), prefix: $this->prefix, ); } From 1cfde8875607c3840405fd658be025a2f5460dee Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:24:52 +0100 Subject: [PATCH 082/134] chore(phpstan): add support to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2b08220a84..97b6cbbab3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -40,6 +40,7 @@ parameters: - packages/reflection - packages/router - packages/storage + - packages/support - tests reportUnmatchedIgnoredErrors: true From 9f4adeee91213ba29feb7a049c04ca71ae90ad8c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:25:08 +0100 Subject: [PATCH 083/134] fix(support): align removeValues and hasValue types --- packages/support/src/Arr/ManipulatesArray.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/support/src/Arr/ManipulatesArray.php b/packages/support/src/Arr/ManipulatesArray.php index 8c4887038f..135fad8e35 100644 --- a/packages/support/src/Arr/ManipulatesArray.php +++ b/packages/support/src/Arr/ManipulatesArray.php @@ -142,7 +142,7 @@ public function forget(string|int|array $keys): self * * @param TValue|array $values The values to remove. */ - public function removeValues(string|int|array $values): self + public function removeValues(mixed $values): self { return $this->createOrModify(remove_values($this->value, $values)); } @@ -567,7 +567,7 @@ public function hasKey(int|string $key): bool /** * Asserts whether the instance contains the specified value. * - * @param TValue: bool $search + * @param TValue|Closure(TValue, TKey): bool $search */ public function hasValue(mixed $search): bool { From 0d20b01cb9f42bc080b15106396547a1209e91c8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:25:20 +0100 Subject: [PATCH 084/134] fix(support): remove dead code in create_temporary_directory --- packages/support/src/Filesystem/functions.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/support/src/Filesystem/functions.php b/packages/support/src/Filesystem/functions.php index f2ca7d980e..f395bfb9fd 100644 --- a/packages/support/src/Filesystem/functions.php +++ b/packages/support/src/Filesystem/functions.php @@ -268,10 +268,6 @@ function create_temporary_directory(?string $prefix = null): string $temporaryDirectory = sys_get_temp_dir(); $uniqueDirectory = $temporaryDirectory . '/' . uniqid(prefix: $prefix ?? ''); - if ($uniqueDirectory === false) { - throw new Exceptions\RuntimeException('Failed to create a temporary directory.'); - } - namespace\ensure_directory_exists($uniqueDirectory); namespace\ensure_directory_empty($uniqueDirectory); From be37159991d36ca9daf2ced8a966bf9e86ec44ff Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:25:32 +0100 Subject: [PATCH 085/134] refactor(support): decouple debug methods from debug package --- packages/support/src/Arr/ManipulatesArray.php | 21 +++++++++++++++++-- .../support/src/Str/ManipulatesString.php | 21 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/support/src/Arr/ManipulatesArray.php b/packages/support/src/Arr/ManipulatesArray.php index 135fad8e35..a723d55f4a 100644 --- a/packages/support/src/Arr/ManipulatesArray.php +++ b/packages/support/src/Arr/ManipulatesArray.php @@ -810,7 +810,7 @@ public function tap(Closure $callback): self */ public function dump(mixed ...$dumps): self { - lw($this->value, ...$dumps); + $this->debugLog([$this->value, ...$dumps]); return $this; } @@ -820,7 +820,24 @@ public function dump(mixed ...$dumps): self */ public function dd(mixed ...$dd): void { - ld($this->value, ...$dd); + $this->debugLog([$this->value, ...$dd], terminate: true); + } + + private function debugLog(array $items, bool $terminate = false): void + { + $debugClass = \Tempest\Debug\Debug::class; + + if (class_exists($debugClass)) { + $debugClass::resolve()->log($items); + } else { + foreach ($items as $item) { + error_log(print_r($item, true)); + } + } + + if ($terminate) { + exit(1); + } } /** diff --git a/packages/support/src/Str/ManipulatesString.php b/packages/support/src/Str/ManipulatesString.php index 784ea71d90..40e86b7866 100644 --- a/packages/support/src/Str/ManipulatesString.php +++ b/packages/support/src/Str/ManipulatesString.php @@ -847,7 +847,7 @@ public function tap(Closure $callback): self */ public function dd(mixed ...$dd): void { - ld($this->value, ...$dd); + $this->debugLog([$this->value, ...$dd], terminate: true); } /** @@ -855,11 +855,28 @@ public function dd(mixed ...$dd): void */ public function dump(mixed ...$dumps): self { - lw($this->value, ...$dumps); + $this->debugLog([$this->value, ...$dumps]); return $this; } + private function debugLog(array $items, bool $terminate = false): void + { + $debugClass = \Tempest\Debug\Debug::class; + + if (class_exists($debugClass)) { + $debugClass::resolve()->log($items); + } else { + foreach ($items as $item) { + error_log(print_r($item, true)); + } + } + + if ($terminate) { + exit(1); + } + } + /** * Decodes the JSON string and returns an array helper instance. */ From 6ab1fb47656c16956a88c05b882554d73f061540 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:25:43 +0100 Subject: [PATCH 086/134] fix(console): use hasKey for duplicate command detection --- packages/console/src/Middleware/ResolveOrRescueMiddleware.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/src/Middleware/ResolveOrRescueMiddleware.php b/packages/console/src/Middleware/ResolveOrRescueMiddleware.php index 8f05f8afe4..1f252fc2e5 100644 --- a/packages/console/src/Middleware/ResolveOrRescueMiddleware.php +++ b/packages/console/src/Middleware/ResolveOrRescueMiddleware.php @@ -84,7 +84,7 @@ private function getSimilarCommands(ImmutableString $search): ImmutableArray $currentName = str($consoleCommand->getName()); // Already added to suggestions - if ($suggestions->hasValue($currentName->toString())) { + if ($suggestions->hasKey($currentName->toString())) { continue; } From 35c439a01c6acbbe3564e14f935a3dc6eb079e41 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:25:53 +0100 Subject: [PATCH 087/134] chore(phpstan): baseline exec() and phar require_once If someone only installs the `support` package, they won't have the `debug` package installed as nothing requires it. --- phpstan-baseline.neon | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 69d214b46f..180e4aeece 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -94,6 +94,16 @@ parameters: path: tests/Integration/Console/Fixtures/LogDebugCommand.php count: 1 # Intentional: lw() is the purpose of this test fixture + - + identifier: disallowed.function + path: packages/support/tests/Filesystem/UnixFunctionsTest.php + count: 1 + # Intentional: exec() restores permissions recursively during Unix test cleanup + - + identifier: requireOnce.fileNotFound + path: packages/support/tests/Fixtures/Phar/normalize_path.php + count: 1 + # Intentional: require_once target exists only inside generated Phar archive at runtime - identifier: function.alreadyNarrowedType path: packages/console/src/GenericConsole.php From 8c712e848d761374cdf0d8bd5acdfbc09d5ab51b Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:37:55 +0100 Subject: [PATCH 088/134] chore(phpstan): add validation to scan paths --- phpstan.neon.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 97b6cbbab3..780906cba7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -41,6 +41,8 @@ parameters: - packages/router - packages/storage - packages/support + #- packages/upgrade - 51 errors + - packages/validation - tests reportUnmatchedIgnoredErrors: true From e72548f20105fdf5f885dbeaaa047c9d774a896f Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:38:01 +0100 Subject: [PATCH 089/134] fix(validation): correct PHPDoc types on Validator --- .../src/Exceptions/ValidationFailed.php | 2 +- packages/validation/src/Validator.php | 20 +++++++++++-------- packages/validation/tests/ValidatorTest.php | 6 +++++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/validation/src/Exceptions/ValidationFailed.php b/packages/validation/src/Exceptions/ValidationFailed.php index 400cb303a1..fa6ee4ac4b 100644 --- a/packages/validation/src/Exceptions/ValidationFailed.php +++ b/packages/validation/src/Exceptions/ValidationFailed.php @@ -13,7 +13,7 @@ final class ValidationFailed extends Exception * @template TKey of array-key * * @param array $failingRules - * @param array $errorMessages + * @param array> $errorMessages * @param class-string|null $targetClass */ public function __construct( diff --git a/packages/validation/src/Validator.php b/packages/validation/src/Validator.php index eca86d2518..7c692884f3 100644 --- a/packages/validation/src/Validator.php +++ b/packages/validation/src/Validator.php @@ -22,6 +22,10 @@ use function Tempest\Support\arr; use function Tempest\Support\str; +/** + * @phpstan-type ValidationClosure Closure(mixed $value): (bool|string|null) + * @phpstan-type ValidationRule Rule|ValidationClosure|false|null + */ #[Singleton] final readonly class Validator { @@ -56,7 +60,7 @@ public function validateObject(object $object): void /** * Creates a {@see ValidationFailed} exception from the given rule failures, populated with error messages. * - * @param array $failingRules + * @param array> $failingRules * @param class-string|null $targetClass */ public function createValidationFailureException(array $failingRules, null|object|string $subject = null, ?string $targetClass = null): ValidationFailed @@ -75,7 +79,7 @@ public function createValidationFailureException(array $failingRules, null|objec * Validates the specified `$values` for the corresponding public properties on the specified `$class`, using built-in PHP types and attribute rules. * * @param ClassReflector|class-string $class - * @return array + * @return array> */ public function validateValuesForClass(ClassReflector|string $class, ?array $values, string $prefix = ''): array { @@ -126,7 +130,7 @@ class: $property->getType()->asClass(), /** * Validates `$value` against the specified `$property`, using built-in PHP types and attribute rules. * - * @return FailingRule[] + * @return list */ public function validateValueForProperty(PropertyReflector $property, mixed $value): array { @@ -160,8 +164,8 @@ public function validateValueForProperty(PropertyReflector $property, mixed $val /** * Validates the specified `$value` against the specified set of `$rules`. If a rule is a closure, it may return a string as a validation error. * - * @param Rule|array|(Closure(mixed $value):string|false) $rules - * @return FailingRule[] + * @param Rule|ValidationClosure|array $rules + * @return list */ public function validateValue(mixed $value, Closure|Rule|array $rules): array { @@ -187,10 +191,10 @@ public function validateValue(mixed $value, Closure|Rule|array $rules): array * The `$rules` array is expected to have the same keys as `$values`, associated with instance of {@see Tempest\Validation\Rule}. * If `$rules` doesn't contain a key for a value, it will not be validated. * - * @param array $values - * @param array $rules + * @param iterable $values + * @param array> $rules * - * @return Rule[] + * @return array> */ public function validateValues(iterable $values, array $rules): array { diff --git a/packages/validation/tests/ValidatorTest.php b/packages/validation/tests/ValidatorTest.php index 8b47f9117b..6631364a90 100644 --- a/packages/validation/tests/ValidatorTest.php +++ b/packages/validation/tests/ValidatorTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Tempest\Reflection\ClassReflector; use Tempest\Validation\Exceptions\ValidationFailed; +use Tempest\Validation\HasErrorMessage; use Tempest\Validation\Rules\IsBoolean; use Tempest\Validation\Rules\IsEmail; use Tempest\Validation\Rules\IsEnum; @@ -63,8 +64,11 @@ public function test_closure_fails_with_string_response(): void return 'I expected b'; }); + $rule = $failingRules[0]->rule; + $this->assertCount(1, $failingRules); - $this->assertSame('I expected b', $failingRules[0]->rule->message); + $this->assertInstanceOf(HasErrorMessage::class, $rule); + $this->assertSame('I expected b', $rule->getErrorMessage()); } public function test_closure_passes_with_null_response(): void From 8002524324d66a23c0414472f394940d8ae5c98b Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 05:38:08 +0100 Subject: [PATCH 090/134] chore(phpstan): baseline IsEnum test type mismatch --- phpstan-baseline.neon | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 180e4aeece..31782be5d2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -109,3 +109,8 @@ parameters: path: packages/console/src/GenericConsole.php count: 1 # Runtime guard: PHPDoc narrows string to class-string, but is_a() check is needed at runtime + - + identifier: argument.type + path: packages/validation/tests/Rules/IsEnumTest.php + count: 1 + # Intentional: constructor accepts only enum class-strings; this test verifies runtime rejection path for invalid input From d7b925b9a80bd01972fa0245e0a03f84d35a2d63 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 06:03:48 +0100 Subject: [PATCH 091/134] refactor(tests): use specific assertions in mapper tests --- .../Mapper/Mappers/ArrayToObjectMapperTestCase.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Mapper/Mappers/ArrayToObjectMapperTestCase.php b/tests/Integration/Mapper/Mappers/ArrayToObjectMapperTestCase.php index abcaa8e977..be123c3e6e 100644 --- a/tests/Integration/Mapper/Mappers/ArrayToObjectMapperTestCase.php +++ b/tests/Integration/Mapper/Mappers/ArrayToObjectMapperTestCase.php @@ -60,7 +60,7 @@ public function test_default_values(): void $object = map([])->to(ObjectWithDefaultValues::class); $this->assertSame('a', $object->a); - $this->assertSame(null, $object->b); + $this->assertNull($object->b); } public function test_built_in_casters(): void @@ -82,7 +82,7 @@ public function test_built_in_casters(): void $this->assertSame('2024-01-01 10:10:10', $object->dateTime->format('Y-m-d H:i:s')); $this->assertSame('2024-12-01 10:10:10', $object->dateTimeWithFormat->format('Y-m-d H:i:s')); $this->assertNull($object->nullableDateTimeImmutable); - $this->assertSame(false, $object->bool); + $this->assertFalse($object->bool); $this->assertSame(0.1, $object->float); $this->assertSame(1, $object->int); } From 24653832a530c4d2e96b168f3f9e57848f247d79 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 06:03:58 +0100 Subject: [PATCH 092/134] fix(mapper): use configured format in DateTimeCaster --- packages/mapper/src/Casters/DateTimeCaster.php | 4 ++++ ...ObjectWithConfiguredTempestDateTimeFormat.php | 16 ++++++++++++++++ tests/Integration/Mapper/MapperTest.php | 12 ++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 tests/Integration/Mapper/Fixtures/ObjectWithConfiguredTempestDateTimeFormat.php diff --git a/packages/mapper/src/Casters/DateTimeCaster.php b/packages/mapper/src/Casters/DateTimeCaster.php index ee1b116e3f..f6b42f4201 100644 --- a/packages/mapper/src/Casters/DateTimeCaster.php +++ b/packages/mapper/src/Casters/DateTimeCaster.php @@ -50,6 +50,10 @@ public function cast(mixed $input): ?DateTimeInterface } try { + if ($this->format !== FormatPattern::ISO8601 && is_string($input)) { + return DateTime::fromPattern($input, $this->format); + } + return DateTime::parse($input); } catch (\Throwable) { return null; diff --git a/tests/Integration/Mapper/Fixtures/ObjectWithConfiguredTempestDateTimeFormat.php b/tests/Integration/Mapper/Fixtures/ObjectWithConfiguredTempestDateTimeFormat.php new file mode 100644 index 0000000000..f99c8fbb9b --- /dev/null +++ b/tests/Integration/Mapper/Fixtures/ObjectWithConfiguredTempestDateTimeFormat.php @@ -0,0 +1,16 @@ +assertSame('2025-03-02', $fromArray->nativeDate->format('Y-m-d')); } + #[Test] + public function map_uses_configured_format_for_tempest_datetime(): void + { + $object = map([ + 'date' => '01/12/2024 10:10:10', + ])->to(ObjectWithConfiguredTempestDateTimeFormat::class); + + $this->assertSame('2024-12-01 10:10:10', $object->date->format('yyyy-MM-dd HH:mm:ss')); + } + public function test_multiple_map_from_source(): void { $object = map(['name' => 'Guillaume'])->to(ObjectWithMultipleMapFrom::class); From 749c97c3baeee0592cfaac52e852b7bbdefbcb7c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 06:43:23 +0100 Subject: [PATCH 093/134] chore(phpstan): add view package to scan paths --- phpstan.neon.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 780906cba7..ce32c8b896 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -43,6 +43,7 @@ parameters: - packages/support #- packages/upgrade - 51 errors - packages/validation + - packages/view - tests reportUnmatchedIgnoredErrors: true From 877c9970a0e16e6dfc8ee6147e071d47243c0d63 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 06:43:32 +0100 Subject: [PATCH 094/134] fix(view): remove dead PhpDataElement class --- packages/view/src/Elements/PhpDataElement.php | 68 ------------------- 1 file changed, 68 deletions(-) delete mode 100644 packages/view/src/Elements/PhpDataElement.php diff --git a/packages/view/src/Elements/PhpDataElement.php b/packages/view/src/Elements/PhpDataElement.php deleted file mode 100644 index 6e2fea996f..0000000000 --- a/packages/view/src/Elements/PhpDataElement.php +++ /dev/null @@ -1,68 +0,0 @@ -wrappingElement; - } - - public function compile(): string - { - // if ($this->unwrap(ViewComponentElement::class)) { - return $this->wrappingElement->compile(); - // } - - $localVariableName = str($this->name)->ltrim(':')->camel()->toString(); - $isExpression = str_starts_with($this->name, ':'); - $value = $this->value ?? ''; - - // We'll declare the variable in PHP right before the actual element - $variableDeclaration = sprintf( - '$_%sIsLocal = $_%sIsLocal ?? isset($%s) === false; $%s ??= %s ?? null;', - $localVariableName, - $localVariableName, - $localVariableName, - $localVariableName, - $isExpression - ? ($value ?: 'null') - : var_export($value, return: true), - ); - - // And we'll remove it right after the element, this way we've created a "local scope" - // where the variable is only available to that specific element. - $variableRemoval = sprintf( - 'if ($_%sIsLocal ?? null) { unset($%s); }; unset($_%sIsLocal)', - $localVariableName, - $localVariableName, - $localVariableName, - ); - - return sprintf( - ' -%s - -', - $variableDeclaration, - $this->wrappingElement->compile(), - $variableRemoval, - ); - } -} From 733ad87355819d22717664b43fc850f8b30af307 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 06:43:41 +0100 Subject: [PATCH 095/134] fix(view): resolve phpstan errors in view package --- packages/view/src/Components/x-component.view.php | 4 +++- packages/view/src/Components/x-icon.view.php | 2 +- packages/view/src/Elements/RawConditionalAttribute.php | 6 ++++-- packages/view/src/Parser/TempestViewLexer.php | 2 -- packages/view/src/Stubs/view.stub.php | 1 - packages/view/src/ViewCachePool.php | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/view/src/Components/x-component.view.php b/packages/view/src/Components/x-component.view.php index fdf0baa252..c35181f9d2 100644 --- a/packages/view/src/Components/x-component.view.php +++ b/packages/view/src/Components/x-component.view.php @@ -22,7 +22,9 @@ HTML, $is, $attributeString, $content, $is); -$html = get(TempestViewRenderer::class)->render(view($template, ...$this->data)); +$data = $scopedVariables ?? $_data ?? []; +$data = is_array($data) ? $data : []; +$html = get(TempestViewRenderer::class)->render(view($template, ...$data)); ?> {!! $html !!} diff --git a/packages/view/src/Components/x-icon.view.php b/packages/view/src/Components/x-icon.view.php index 67905ea976..3a3515ae2c 100644 --- a/packages/view/src/Components/x-icon.view.php +++ b/packages/view/src/Components/x-icon.view.php @@ -1,6 +1,6 @@ Date: Sun, 15 Feb 2026 06:49:31 +0100 Subject: [PATCH 096/134] chore(phpstan): add vite packages to scan paths --- phpstan.neon.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ce32c8b896..3319d9a2f0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -44,6 +44,8 @@ parameters: #- packages/upgrade - 51 errors - packages/validation - packages/view + - packages/vite + - packages/vite-plugin-tempest - tests reportUnmatchedIgnoredErrors: true From 12dea1543c048843f1768328ef17c796e34af471 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:14:12 +0100 Subject: [PATCH 097/134] fix(support): allow non-void return in Arr each() callback --- packages/support/src/Arr/ManipulatesArray.php | 2 +- packages/support/src/Arr/functions.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/support/src/Arr/ManipulatesArray.php b/packages/support/src/Arr/ManipulatesArray.php index a723d55f4a..e038d9f17a 100644 --- a/packages/support/src/Arr/ManipulatesArray.php +++ b/packages/support/src/Arr/ManipulatesArray.php @@ -482,7 +482,7 @@ public function filter(?Closure $filter = null): self /** * Applies the given callback to all items of the instance. * - * @param Closure(mixed $value, mixed $key): void $each + * @param Closure(TValue, TKey): mixed $each */ public function each(Closure $each): self { diff --git a/packages/support/src/Arr/functions.php b/packages/support/src/Arr/functions.php index 009078e123..96c97c720a 100644 --- a/packages/support/src/Arr/functions.php +++ b/packages/support/src/Arr/functions.php @@ -813,7 +813,7 @@ function filter(iterable $array, ?Closure $filter = null): array * @template TValue * * @param iterable $array - * @param Closure(TValue $value, TKey $key): void $each + * @param Closure(TValue $value, TKey $key): mixed $each * * @return array */ From 828e43ff755c49cee2a377b10e4853cc40643935 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:14:20 +0100 Subject: [PATCH 098/134] fix(view): remove stale phpstan-ignore-next-line comments --- packages/view/src/ViewCachePool.php | 1 - packages/view/tests/ViewCachePoolTest.php | 1 - tests/Integration/View/ViewCacheTest.php | 1 - 3 files changed, 3 deletions(-) diff --git a/packages/view/src/ViewCachePool.php b/packages/view/src/ViewCachePool.php index d736e0747e..81e68393d4 100644 --- a/packages/view/src/ViewCachePool.php +++ b/packages/view/src/ViewCachePool.php @@ -58,7 +58,6 @@ public function clear(): bool $path = path($this->directory); if ($path->isDirectory()) { - /** @phpstan-ignore-next-line */ $path->glob('/*.php')->each(fn (string $file) => unlink($file)); Filesystem\delete_directory($this->directory); diff --git a/packages/view/tests/ViewCachePoolTest.php b/packages/view/tests/ViewCachePoolTest.php index 53fd84e791..bb87c8c71f 100644 --- a/packages/view/tests/ViewCachePoolTest.php +++ b/packages/view/tests/ViewCachePoolTest.php @@ -33,7 +33,6 @@ protected function tearDown(): void $directory = path(self::DIRECTORY); if ($directory->isDirectory()) { - /** @phpstan-ignore-next-line */ $directory->glob('/*.php')->each(fn (string $file) => unlink($file)); rmdir(self::DIRECTORY); diff --git a/tests/Integration/View/ViewCacheTest.php b/tests/Integration/View/ViewCacheTest.php index a594a6166b..0a805812fd 100644 --- a/tests/Integration/View/ViewCacheTest.php +++ b/tests/Integration/View/ViewCacheTest.php @@ -37,7 +37,6 @@ protected function tearDown(): void $directory = path(self::DIRECTORY); if ($directory->isDirectory()) { - /** @phpstan-ignore-next-line */ $directory->glob('/*.php')->each(fn (string $file) => unlink($file)); rmdir(self::DIRECTORY); From e886878b8fb6ee62db3edcac8a75b973cc4ebd02 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:14:28 +0100 Subject: [PATCH 099/134] fix(process): resolve phpstan errors in process package --- packages/process/src/InvokedProcess.php | 2 +- packages/process/src/OutputChannel.php | 1 + packages/process/src/PendingProcess.php | 12 ++++++------ .../src/Testing/InvokedProcessDescription.php | 6 +++++- .../src/Testing/InvokedTestingProcess.php | 19 +++++++++---------- .../process/src/Testing/ProcessTester.php | 12 ++++++------ .../src/Testing/TestingProcessExecutor.php | 2 +- 7 files changed, 29 insertions(+), 25 deletions(-) diff --git a/packages/process/src/InvokedProcess.php b/packages/process/src/InvokedProcess.php index d4a96f8a86..7465f258c3 100644 --- a/packages/process/src/InvokedProcess.php +++ b/packages/process/src/InvokedProcess.php @@ -50,7 +50,7 @@ public function stop(float|int|Duration $timeout = 10, ?int $signal = null): sel /** * Waits for the process to finish. * - * @param null|callable(OutputChannel,string) $output The callback receives the type of output (out or err) and some bytes from the output in real-time while writing the standard input to the process. It allows to have feedback from the independent process during execution. + * @param null|callable(OutputChannel, string): void $output The callback receives the type of output (out or err) and some bytes from the output in real-time while writing the standard input to the process. It allows to have feedback from the independent process during execution. */ public function wait(?callable $output = null): ProcessResult; } diff --git a/packages/process/src/OutputChannel.php b/packages/process/src/OutputChannel.php index 1eaea91b3b..fa3f546c6a 100644 --- a/packages/process/src/OutputChannel.php +++ b/packages/process/src/OutputChannel.php @@ -14,6 +14,7 @@ public static function fromSymfonyOutputType(string $type): self return match ($type) { Process::OUT => self::OUTPUT, Process::ERR => self::ERROR, + default => throw new \UnexpectedValueException(sprintf('Unsupported output type "%s".', $type)), }; } } diff --git a/packages/process/src/PendingProcess.php b/packages/process/src/PendingProcess.php index ba2f018f90..4fd6a2e118 100644 --- a/packages/process/src/PendingProcess.php +++ b/packages/process/src/PendingProcess.php @@ -11,12 +11,12 @@ final class PendingProcess { /** * @param array|string $command The command to run and its arguments listed as separate entries. - * @param null|Duration $timeout Sets the process timeout (max. runtime). - * @param null|Duration $idleTimeout Sets the process idle timeout (max. time since last output). - * @param null|string $path Working directory for the process. - * @param null|string $input Content that will be passed to the underlying process standard input. - * @param null|bool $quietly Disables fetching output and error output from the underlying process. - * @param null|bool $tty If set to `true`, forces enabling TTY mode. + * @param Duration|null $timeout Sets the process timeout (max. runtime). + * @param Duration|null $idleTimeout Sets the process idle timeout (max. time since last output). + * @param string|null $path Working directory for the process. + * @param string|null $input Content that will be passed to the underlying process standard input. + * @param bool $quietly Disables fetching output and error output from the underlying process. + * @param bool $tty If set to `true`, forces enabling TTY mode. * @param array $environment Environment variables to set for the process. * @param array $options Underlying `proc_open` options. */ diff --git a/packages/process/src/Testing/InvokedProcessDescription.php b/packages/process/src/Testing/InvokedProcessDescription.php index 1f3c2e650a..fa0a942d74 100644 --- a/packages/process/src/Testing/InvokedProcessDescription.php +++ b/packages/process/src/Testing/InvokedProcessDescription.php @@ -16,7 +16,7 @@ final class InvokedProcessDescription /** * The process output, in order it should be received. * - * @var string[] + * @var array */ public array $output = []; @@ -42,6 +42,8 @@ public function pid(int $pid): self /** * Describes a line of standard output in the order it should be received. + * + * @param string|array $output */ public function output(string|array $output): self { @@ -61,6 +63,8 @@ public function output(string|array $output): self /** * Describes a line of error output in the order it should be received. + * + * @param string|array $errorOutput */ public function errorOutput(string|array $errorOutput): self { diff --git a/packages/process/src/Testing/InvokedTestingProcess.php b/packages/process/src/Testing/InvokedTestingProcess.php index 723015b692..9a19383025 100644 --- a/packages/process/src/Testing/InvokedTestingProcess.php +++ b/packages/process/src/Testing/InvokedTestingProcess.php @@ -69,6 +69,8 @@ final class InvokedTestingProcess implements InvokedProcess /** * The general output handler callback. + * + * @var null|\Closure(OutputChannel, string): void */ private ?\Closure $outputHandler = null; @@ -95,11 +97,6 @@ final class InvokedTestingProcess implements InvokedProcess */ private int $nextErrorOutputIndex = 0; - /** - * The signals that have been received. - */ - private array $receivedSignals = []; - public function __construct( private readonly InvokedProcessDescription $description, ) {} @@ -108,8 +105,6 @@ public function signal(int $signal): self { $this->invokeOutputHandlerWithNextLineOfOutput(); - $this->receivedSignals[] = $signal; - return $this; } @@ -122,7 +117,11 @@ public function stop(float|int|Duration $timeout = 10, ?int $signal = null): sel public function wait(?callable $output = null): ProcessResult { - $this->outputHandler = $output ?: $this->outputHandler; + if ($output !== null) { + $this->outputHandler = $output instanceof \Closure + ? $output + : \Closure::fromCallable($output); + } if (! $this->outputHandler) { $this->remainingRunIterations = 0; @@ -203,7 +202,7 @@ private function invokeOutputHandlerWithNextLineOfOutput(): bool $this->nextOutputIndex = $i + 1; - return $currentOutput; + return true; } if ($currentOutput['type'] === OutputChannel::ERROR && $i >= $this->nextErrorOutputIndex) { @@ -211,7 +210,7 @@ private function invokeOutputHandlerWithNextLineOfOutput(): bool $this->nextErrorOutputIndex = $i + 1; - return $currentOutput; + return true; } } diff --git a/packages/process/src/Testing/ProcessTester.php b/packages/process/src/Testing/ProcessTester.php index 0d8fb4cdb1..ba75c8d330 100644 --- a/packages/process/src/Testing/ProcessTester.php +++ b/packages/process/src/Testing/ProcessTester.php @@ -104,12 +104,12 @@ public function disableProcessExecution(): void /** * Stops the process and dumps the recorded process executions. - * - * @mago-expect lint:no-debug-symbols */ public function debugExecutedProcesses(): never { - dd($this->executor->executions); + $this->ensureTestingSetUp(); + + throw new \RuntimeException(var_export($this->executor->executions, true)); } /** @@ -123,7 +123,7 @@ public function describe(): InvokedProcessDescription /** * Asserts that the given command has been ran. Alternatively, a callback may be passed. * - * @param (\Closure(ProcessResult,PendingProcess=):false|void)|string $command + * @param null|Closure(): mixed|Closure(ProcessResult): mixed|Closure(ProcessResult, PendingProcess): mixed $callback */ public function assertCommandRan(string $command, ?\Closure $callback = null): self { @@ -158,7 +158,7 @@ public function assertCommandRan(string $command, ?\Closure $callback = null): s /** * Asserts that the a command has been ran by the given callback. * - * @param \Closure(PendingProcess,ProcessResult=):false|void $callback + * @param Closure(PendingProcess): mixed|Closure(PendingProcess, ProcessResult): mixed $callback */ public function assertRan(\Closure $callback): self { @@ -186,7 +186,7 @@ public function assertRan(\Closure $callback): self /** * Asserts that the given command did not run. Alternatively, a callback may be passed. * - * @param (\Closure(PendingProcess,ProcessResult=):false|void)|string $command + * @param string|Closure(): mixed|Closure(PendingProcess): mixed|Closure(PendingProcess, ProcessResult): mixed $command */ public function assertCommandDidNotRun(string|\Closure $command): self { diff --git a/packages/process/src/Testing/TestingProcessExecutor.php b/packages/process/src/Testing/TestingProcessExecutor.php index 9f0308d958..0b5381e186 100644 --- a/packages/process/src/Testing/TestingProcessExecutor.php +++ b/packages/process/src/Testing/TestingProcessExecutor.php @@ -19,7 +19,7 @@ final class TestingProcessExecutor implements ProcessExecutor private(set) array $executions = []; /** - * @param array $mocks + * @param array $mocks */ public function __construct( private readonly GenericProcessExecutor $executor, From b1663ac310babde25a1cddbdc83926848fe8fd76 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:14:36 +0100 Subject: [PATCH 100/134] chore(phpstan): add process package to scan paths --- phpstan.neon.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3319d9a2f0..802da6fc3a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -36,7 +36,7 @@ parameters: - packages/log - packages/mail - packages/mapper - #- packages/process - 31 errors + - packages/process - packages/reflection - packages/router - packages/storage From 66a1c7573530e822b0b69b23944d3e66d19aa794 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:14:44 +0100 Subject: [PATCH 101/134] chore(phpstan): baseline ProcessTester assertTrue calls --- phpstan-baseline.neon | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 31782be5d2..d471a9dbe0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -114,3 +114,8 @@ parameters: path: packages/validation/tests/Rules/IsEnumTest.php count: 1 # Intentional: constructor accepts only enum class-strings; this test verifies runtime rejection path for invalid input + - + identifier: staticMethod.alreadyNarrowedType + path: packages/process/src/Testing/ProcessTester.php + count: 3 + # Intentional: explicit assertTrue() calls keep callback-only branches counted as assertions in tests From a43cdf9d803a99c0a94ec7b59debe8218db07565 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:21:54 +0100 Subject: [PATCH 102/134] fix(upgrade): match Rector refactor() return type contract --- packages/upgrade/src/Tempest2/MigrationRector.php | 8 +++++--- .../upgrade/src/Tempest28/WriteableRouteRector.php | 10 ++++++---- .../src/Tempest3/UpdateExceptionProcessorRector.php | 10 ++++++---- .../upgrade/src/Tempest3/UpdateHasContextRector.php | 10 ++++++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/upgrade/src/Tempest2/MigrationRector.php b/packages/upgrade/src/Tempest2/MigrationRector.php index 414cfe2c4e..d0c36722f4 100644 --- a/packages/upgrade/src/Tempest2/MigrationRector.php +++ b/packages/upgrade/src/Tempest2/MigrationRector.php @@ -16,10 +16,10 @@ public function getNodeTypes(): array ]; } - public function refactor(Node $node): void + public function refactor(Node $node): ?int { if (! $node instanceof Node\Stmt\Class_) { - return; + return null; } // Check whether this class implements Tempest\Database\DatabaseMigration @@ -31,7 +31,7 @@ public function refactor(Node $node): void ); if ($implementsDatabaseMigration === null) { - return; + return null; } // Unset the old interface @@ -84,5 +84,7 @@ public function refactor(Node $node): void } $node->implements = $implements; + + return null; } } diff --git a/packages/upgrade/src/Tempest28/WriteableRouteRector.php b/packages/upgrade/src/Tempest28/WriteableRouteRector.php index 05d4c658f6..630a8055e3 100644 --- a/packages/upgrade/src/Tempest28/WriteableRouteRector.php +++ b/packages/upgrade/src/Tempest28/WriteableRouteRector.php @@ -16,10 +16,10 @@ public function getNodeTypes(): array ]; } - public function refactor(Node $node): void + public function refactor(Node $node): ?int { if (! $node instanceof Node\Stmt\Class_) { - return; + return null; } // Check whether this class implements Tempest\Router\Route @@ -31,13 +31,15 @@ public function refactor(Node $node): void ); if ($implementsRoute === null) { - return; + return null; } if (! $node->isReadonly()) { - return; + return null; } $node->flags &= ~Modifiers::READONLY; + + return null; } } diff --git a/packages/upgrade/src/Tempest3/UpdateExceptionProcessorRector.php b/packages/upgrade/src/Tempest3/UpdateExceptionProcessorRector.php index 26bc1f9ab3..7bb7dc54a4 100644 --- a/packages/upgrade/src/Tempest3/UpdateExceptionProcessorRector.php +++ b/packages/upgrade/src/Tempest3/UpdateExceptionProcessorRector.php @@ -17,7 +17,7 @@ public function getNodeTypes(): array ]; } - public function refactor(Node $node): void + public function refactor(Node $node): ?int { if ($node instanceof Node\UseItem) { $name = $node->name->toString(); @@ -26,11 +26,11 @@ public function refactor(Node $node): void $node->name = new Node\Name('Tempest\Core\Exceptions\ExceptionReporter'); } - return; + return null; } if (! $node instanceof Node\Stmt\Class_) { - return; + return null; } $implements = $node->implements; @@ -41,7 +41,7 @@ public function refactor(Node $node): void ); if ($implementsExceptionProcessor === null) { - return; + return null; } $implements[$implementsExceptionProcessor] = new Node\Name('\Tempest\Core\Exceptions\ExceptionReporter'); @@ -57,5 +57,7 @@ public function refactor(Node $node): void break; } } + + return null; } } diff --git a/packages/upgrade/src/Tempest3/UpdateHasContextRector.php b/packages/upgrade/src/Tempest3/UpdateHasContextRector.php index 2547057a01..20c115c46d 100644 --- a/packages/upgrade/src/Tempest3/UpdateHasContextRector.php +++ b/packages/upgrade/src/Tempest3/UpdateHasContextRector.php @@ -15,7 +15,7 @@ public function getNodeTypes(): array ]; } - public function refactor(Node $node): void + public function refactor(Node $node): ?int { if ($node instanceof Node\UseItem) { $name = $node->name->toString(); @@ -24,11 +24,11 @@ public function refactor(Node $node): void $node->name = new Node\Name('Tempest\Core\ProvidesContext'); } - return; + return null; } if (! $node instanceof Node\Stmt\Class_) { - return; + return null; } $implements = $node->implements; @@ -39,10 +39,12 @@ public function refactor(Node $node): void ); if ($implementsHasContext === null) { - return; + return null; } $implements[$implementsHasContext] = new Node\Name('\Tempest\Core\ProvidesContext'); $node->implements = $implements; + + return null; } } From 28d1088ced5f556ba40d2cdf4961b6a41e97a454 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:22:03 +0100 Subject: [PATCH 103/134] fix(upgrade): remove disallowed dd() from RectorTester --- packages/upgrade/tests/RectorTester.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/upgrade/tests/RectorTester.php b/packages/upgrade/tests/RectorTester.php index 1bb8fecbfc..b4925df5d0 100644 --- a/packages/upgrade/tests/RectorTester.php +++ b/packages/upgrade/tests/RectorTester.php @@ -96,12 +96,4 @@ private function cleanDiff(string $diff): string return trim($diff); } - - /** - * @mago-expect lint:no-debug-symbols - */ - public function dd(): never - { - dd($this->actual); - } } From 881f8c0918bfb23365195ec50fd64ce25c3aa35e Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 07:22:13 +0100 Subject: [PATCH 104/134] chore(phpstan): add upgrade package to scan paths --- phpstan.neon.dist | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 802da6fc3a..86a7b0ff0c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -41,12 +41,15 @@ parameters: - packages/router - packages/storage - packages/support - #- packages/upgrade - 51 errors + - packages/upgrade - packages/validation - packages/view - packages/vite - packages/vite-plugin-tempest - tests + excludePaths: + analyse: + - packages/upgrade/tests/*/Fixtures/*.php reportUnmatchedIgnoredErrors: true disallowedFunctionCalls: From 51964d3185e0b6a169eae4a475adc090e664fb32 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:29:35 +0100 Subject: [PATCH 105/134] refactor(support): improve generics --- .../support/src/Conditions/HasConditions.php | 8 ++-- .../src/JavaScript/DependencyInstaller.php | 30 ++++++++++---- .../support/src/JavaScript/PackageManager.php | 3 +- packages/support/src/Math/functions.php | 6 +-- .../support/src/Paginator/PaginatedData.php | 41 ++++++++++++++++++- packages/support/src/Paginator/Paginator.php | 2 + packages/support/src/Path/Path.php | 12 +++++- packages/support/src/Random/functions.php | 2 + packages/support/src/Regex/functions.php | 19 ++++++++- .../support/src/Str/ManipulatesString.php | 27 +++++++++--- .../tests/Conditions/HasConditionsTest.php | 10 ++--- 11 files changed, 128 insertions(+), 32 deletions(-) diff --git a/packages/support/src/Conditions/HasConditions.php b/packages/support/src/Conditions/HasConditions.php index 431b52e77b..2e31e59a17 100644 --- a/packages/support/src/Conditions/HasConditions.php +++ b/packages/support/src/Conditions/HasConditions.php @@ -12,9 +12,9 @@ trait HasConditions * Applies the given `$callback` if the `$condition` is true. * * @param mixed|Closure(static): bool $condition - * @param Closure(static): self $callback + * @param Closure(static): mixed $callback */ - public function when(mixed $condition, Closure $callback): self + public function when(mixed $condition, Closure $callback): static { if ($condition instanceof Closure) { $condition = $condition($this); @@ -31,9 +31,9 @@ public function when(mixed $condition, Closure $callback): self * Applies the given `$callback` if the `$condition` is false. * * @param mixed|Closure(static): bool $condition - * @param Closure(static): self $callback + * @param Closure(static): mixed $callback */ - public function unless(mixed $condition, Closure $callback): self + public function unless(mixed $condition, Closure $callback): static { if ($condition instanceof Closure) { $condition = $condition($this); diff --git a/packages/support/src/JavaScript/DependencyInstaller.php b/packages/support/src/JavaScript/DependencyInstaller.php index a19950864e..762c687557 100644 --- a/packages/support/src/JavaScript/DependencyInstaller.php +++ b/packages/support/src/JavaScript/DependencyInstaller.php @@ -22,6 +22,8 @@ public function __construct( /** * Installs the specified JavaScript dependencies. * The package manager will be detected from the lockfile present in `$cwd`. If none found, it will be prompted to the user. + * + * @param non-empty-string|list $dependencies */ public function installDependencies(string $cwd, string|array $dependencies, bool $dev = false): void { @@ -40,6 +42,8 @@ public function installDependencies(string $cwd, string|array $dependencies, boo /** * Installs dependencies without interacting with the console. + * + * @param non-empty-string|list $dependencies */ public function silentlyInstallDependencies(string $cwd, string|array $dependencies, bool $dev = false, PackageManager $defaultPackageManager = PackageManager::NPM): void { @@ -55,19 +59,27 @@ public function silentlyInstallDependencies(string $cwd, string|array $dependenc /** * Gets the `Process` instance that will install the specified dependencies. + * + * @param non-empty-string|list $dependencies */ private function getInstallProcess(PackageManager $packageManager, string $cwd, string|array $dependencies, bool $dev = false): Process { + /** @var list $dependencies */ + $dependencies = array_values(wrap($dependencies)); + + /** @var list $command */ + $command = array_values(array_filter( + [ + $packageManager->getBinaryName(), + $packageManager->getInstallCommand(), + $dev ? '-D' : null, + ...$dependencies, + ], + fn (?string $arg): bool => $arg !== null, + )); + return new Process( - array_filter( - [ - $packageManager->getBinaryName(), - $packageManager->getInstallCommand(), - $dev ? '-D' : null, - ...wrap($dependencies), - ], - fn (?string $arg): bool => $arg !== null, - ), + $command, $cwd, ); } diff --git a/packages/support/src/JavaScript/PackageManager.php b/packages/support/src/JavaScript/PackageManager.php index a244b4c864..df5ddd55e6 100644 --- a/packages/support/src/JavaScript/PackageManager.php +++ b/packages/support/src/JavaScript/PackageManager.php @@ -17,6 +17,7 @@ enum PackageManager: string case YARN = 'yarn'; case NPM = 'npm'; + /** @return list */ public function getLockFiles(): array { return match ($this) { @@ -61,7 +62,7 @@ public static function detect(string $cwd): ?self { return array_find( array: PackageManager::cases(), - callback: fn ($packageManager) => array_any($packageManager->getLockFiles(), fn ($lockFile) => Filesystem\is_file($cwd . '/' . $lockFile)), + callback: fn (PackageManager $packageManager): bool => array_any($packageManager->getLockFiles(), fn (string $lockFile): bool => Filesystem\is_file($cwd . '/' . $lockFile)), ); } } diff --git a/packages/support/src/Math/functions.php b/packages/support/src/Math/functions.php index 722bf80528..9fbc603730 100644 --- a/packages/support/src/Math/functions.php +++ b/packages/support/src/Math/functions.php @@ -47,11 +47,7 @@ function sqrt(float $number): float /** * Returns the absolute value of the given number. * - * @template T of int|float - * - * @param T $number - * - * @return T + * @return ($number is int ? int<0, max> : float) */ function abs(int|float $number): int|float { diff --git a/packages/support/src/Paginator/PaginatedData.php b/packages/support/src/Paginator/PaginatedData.php index 4a77f44c22..9ddce19573 100644 --- a/packages/support/src/Paginator/PaginatedData.php +++ b/packages/support/src/Paginator/PaginatedData.php @@ -11,6 +11,7 @@ final class PaginatedData implements JsonSerializable { /** * @param array $data + * @param list $pageRange */ public function __construct( public array $data, @@ -41,7 +42,7 @@ public function __construct( /** * @template U - * @param callable(mixed): U $callback + * @param callable(T): U $callback * @return PaginatedData */ public function map(callable $callback): self @@ -62,6 +63,25 @@ public function map(callable $callback): self ); } + /** + * @return array{ + * data: array, + * pagination: array{ + * current_page: int, + * total_pages: int, + * total_items: int, + * items_per_page: int, + * offset: int, + * limit: int, + * has_next: bool, + * has_previous: bool, + * next_page: ?int, + * previous_page: ?int, + * page_range: list, + * count: int + * } + * } + */ public function toArray(): array { return [ @@ -83,6 +103,25 @@ public function toArray(): array ]; } + /** + * @return array{ + * data: array, + * pagination: array{ + * current_page: int, + * total_pages: int, + * total_items: int, + * items_per_page: int, + * offset: int, + * limit: int, + * has_next: bool, + * has_previous: bool, + * next_page: ?int, + * previous_page: ?int, + * page_range: list, + * count: int + * } + * } + */ public function jsonSerialize(): array { return $this->toArray(); diff --git a/packages/support/src/Paginator/Paginator.php b/packages/support/src/Paginator/Paginator.php index 44e23adb7d..df810a20e4 100644 --- a/packages/support/src/Paginator/Paginator.php +++ b/packages/support/src/Paginator/Paginator.php @@ -67,6 +67,7 @@ public function __construct( get => $this->totalPages > 0 ? $this->totalPages : null; } + /** @var list */ public array $pageRange { get => $this->calculatePageRange(); } @@ -128,6 +129,7 @@ public function paginateWith(callable $callback): PaginatedData return $this->paginate($callback($this->limit, $this->offset)); } + /** @return list */ private function calculatePageRange(): array { if ($this->totalPages <= $this->maxLinks) { diff --git a/packages/support/src/Path/Path.php b/packages/support/src/Path/Path.php index 11b0875b98..f5f3206209 100644 --- a/packages/support/src/Path/Path.php +++ b/packages/support/src/Path/Path.php @@ -29,6 +29,8 @@ protected function createOrModify(Stringable|string $string): self /** * Returns information about the path. See {@see pathinfo()}. + * + * @return ($flags is PATHINFO_ALL ? array{dirname: string, basename: string, extension?: string, filename: string} : string) */ public function info(int $flags = PATHINFO_ALL): string|array { @@ -77,10 +79,18 @@ public function extension(): self /** * Appends a glob and returns an immutable array with the resulting paths. + * + * @return ImmutableArray */ public function glob(string $pattern): ImmutableArray { - return new ImmutableArray(glob(namespace\normalize($this->value, $pattern))); + $paths = glob(namespace\normalize($this->value, $pattern)); + + if ($paths === false) { + return new ImmutableArray(); + } + + return new ImmutableArray($paths); } /** diff --git a/packages/support/src/Random/functions.php b/packages/support/src/Random/functions.php index c47fb998a3..bd04943cf7 100644 --- a/packages/support/src/Random/functions.php +++ b/packages/support/src/Random/functions.php @@ -23,6 +23,8 @@ * @param int<0, max> $length The length of the string to generate. * * @throws InvalidArgumentException If $alphabet length is outside the [2^1, 2^56] range. + * + * @return ($length is 0 ? '' : non-empty-string) */ function secure_string(int $length, ?string $alphabet = null): string { diff --git a/packages/support/src/Regex/functions.php b/packages/support/src/Regex/functions.php index 21591270d3..5305d59713 100644 --- a/packages/support/src/Regex/functions.php +++ b/packages/support/src/Regex/functions.php @@ -22,6 +22,7 @@ * * @param non-empty-string $pattern The pattern to match against. * @param 0|2|256|512|768 $flags + * @return array|array>|list> */ function get_matches(Stringable|string $subject, Stringable|string $pattern, bool $global = false, int $flags = 0, int $offset = 0): array { @@ -61,6 +62,7 @@ function get_matches(Stringable|string $subject, Stringable|string $pattern, boo * Returns the specified matches of `$pattern` in `$subject`. * * @param non-empty-string $pattern The pattern to match against. + * @return list> */ function get_all_matches( Stringable|string $subject, @@ -68,6 +70,7 @@ function get_all_matches( Stringable|string|int|array $matches = 0, int $offset = 0, ): array { + /** @var list> $result */ $result = get_matches($subject, $pattern, true, PREG_SET_ORDER, $offset); return arr($result) @@ -78,8 +81,22 @@ function get_all_matches( /** * Returns the specified match of `$pattern` in `$subject`. If no match is specified, returns the whole matching array. * + * @template TMatch of null|array|Stringable|int|string + * @template TDefault + * * @param non-empty-string $pattern The pattern to match against. + * @param TMatch $match + * @param TDefault $default * @param 0|256|512|768 $flags + * @return ( + * TMatch is null + * ? array + * : ( + * TMatch is array + * ? array + * : string|array{0: string|null, 1: int}|null|TDefault + * ) + * ) */ function get_match( Stringable|string $subject, @@ -88,7 +105,7 @@ function get_match( mixed $default = null, int $flags = 0, int $offset = 0, -): null|int|string|array { +): mixed { $result = get_matches($subject, $pattern, false, $flags, $offset); if ($match === null) { diff --git a/packages/support/src/Str/ManipulatesString.php b/packages/support/src/Str/ManipulatesString.php index 40e86b7866..140f4e72cb 100644 --- a/packages/support/src/Str/ManipulatesString.php +++ b/packages/support/src/Str/ManipulatesString.php @@ -255,6 +255,8 @@ public function sentence(): self /** * Returns an array of words from the current string. + * + * @return ImmutableArray */ public function words(): ImmutableArray { @@ -451,6 +453,8 @@ public function unwrap(string|Stringable $before, string|Stringable|null $after /** * Extracts an excerpt from the instance. + * + * @return ($asArray is true ? ImmutableArray : self) */ public function excerpt(int $from, int $to, bool $asArray = false): self|ImmutableArray { @@ -650,6 +654,8 @@ public function padRight(int $totalLength, string $padString = ' '): self /** * Chunks the instance into parts of the specified `$length`. + * + * @return ImmutableArray */ public function chunk(int $length): ImmutableArray { @@ -658,6 +664,8 @@ public function chunk(int $length): ImmutableArray /** * Explodes the string into an {@see \Tempest\Support\Arr\ImmutableArray} instance by a separator. + * + * @return ImmutableArray */ public function explode(string $separator = ' ', int $limit = PHP_INT_MAX): ImmutableArray { @@ -704,12 +712,15 @@ public function replaceRegex(array|string $regex, array|string|callable $replace * str('10-abc')->match('/(?\d+-)/', match: 'id'); // 10 * ``` * + * @template TDefault + * * @param non-empty-string $pattern The regular expression to match on - * @param string|int $match The group number or name to retrieve - * @param mixed $default The default value to return if no match is found + * @param array|string|int $match The group number, name, or list of groups to retrieve + * @param TDefault $default The default value to return if no match is found * @param 0|256|512|768 $flags + * @return ($match is array ? array : string|array{0: string|null, 1: int}|null|TDefault) */ - public function match(string $pattern, array|Stringable|int|string $match = 1, mixed $default = null, int $flags = 0, int $offset = 0): null|int|string|array + public function match(string $pattern, array|Stringable|int|string $match = 1, mixed $default = null, int $flags = 0, int $offset = 0): mixed { return Regex\get_match($this->value, $pattern, $match, $default, $flags, $offset); } @@ -718,6 +729,7 @@ public function match(string $pattern, array|Stringable|int|string $match = 1, m * Gets all portions of the instance that match the given regular expression. * * @param non-empty-string $pattern The regular expression to match on + * @return ImmutableArray> */ public function matchAll(Stringable|string $pattern, array|Stringable|int|string $matches = 0, int $offset = 0): ImmutableArray { @@ -879,6 +891,8 @@ private function debugLog(array $items, bool $terminate = false): void /** * Decodes the JSON string and returns an array helper instance. + * + * @return ImmutableArray */ public function decodeJson(): ImmutableArray { @@ -909,8 +923,11 @@ public function __toString(): string return $this->value; } - public static function __set_state(array $array): object + /** + * @param array{value: string} $array + */ + public static function __set_state(array $array): static { - return new self($array['value']); + return new static($array['value']); } } diff --git a/packages/support/tests/Conditions/HasConditionsTest.php b/packages/support/tests/Conditions/HasConditionsTest.php index 406bcb8447..ee994570e3 100644 --- a/packages/support/tests/Conditions/HasConditionsTest.php +++ b/packages/support/tests/Conditions/HasConditionsTest.php @@ -20,7 +20,7 @@ public function test_when(): void public bool $value = false; }; - $class->when(true, fn ($c) => $c->value = true); // @phpstan-ignore-line + $class->when(true, fn ($c) => $c->value = true); $this->assertTrue($class->value); } @@ -33,7 +33,7 @@ public function test_when_with_callback(): void public bool $value = false; }; - $class->when(fn () => true, fn ($c) => $c->value = true); // @phpstan-ignore-line + $class->when(fn () => true, fn ($c) => $c->value = true); $this->assertTrue($class->value); } @@ -46,7 +46,7 @@ public function test_unless(): void public bool $value = false; }; - $class->unless(true, fn ($c) => $c->value = true); // @phpstan-ignore-line + $class->unless(true, fn ($c) => $c->value = true); $this->assertFalse($class->value); } @@ -59,7 +59,7 @@ public function test_unless_with_callback(): void public bool $value = false; }; - $class->unless(fn () => true, fn ($c) => $c->value = true); // @phpstan-ignore-line + $class->unless(fn () => true, fn ($c) => $c->value = true); $this->assertFalse($class->value); } @@ -80,7 +80,7 @@ public function append(string $string): self } }; - $class->when(true, function ($c): void { // @phpstan-ignore-line + $class->when(true, function ($c): void { $c->append('bar'); }); From 586115e01cd38a4b49603774699e9dc59f09e897 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:34:58 +0100 Subject: [PATCH 106/134] fix(intl): add missing imports for AST node types --- .../src/MessageFormat/Parser/Node/ComplexBody/Matcher.php | 1 + .../src/MessageFormat/Parser/Node/ComplexBody/Variant.php | 1 + .../intl/src/MessageFormat/Parser/Node/ComplexMessage.php | 1 + .../intl/src/MessageFormat/Parser/Node/Markup/Markup.php | 6 ++++-- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Matcher.php b/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Matcher.php index 50a28aa3b7..016ca1f971 100644 --- a/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Matcher.php +++ b/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Matcher.php @@ -3,6 +3,7 @@ namespace Tempest\Intl\MessageFormat\Parser\Node\ComplexBody; use Tempest\Intl\MessageFormat\Parser\Node\Pattern\Pattern; +use Tempest\Intl\MessageFormat\Parser\Node\Variable; final readonly class Matcher implements ComplexBody { diff --git a/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Variant.php b/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Variant.php index 24538b2a10..d8380954e5 100644 --- a/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Variant.php +++ b/packages/intl/src/MessageFormat/Parser/Node/ComplexBody/Variant.php @@ -2,6 +2,7 @@ namespace Tempest\Intl\MessageFormat\Parser\Node\ComplexBody; +use Tempest\Intl\MessageFormat\Parser\Node\Key\Key; use Tempest\Intl\MessageFormat\Parser\Node\Node; use Tempest\Intl\MessageFormat\Parser\Node\Pattern\QuotedPattern; diff --git a/packages/intl/src/MessageFormat/Parser/Node/ComplexMessage.php b/packages/intl/src/MessageFormat/Parser/Node/ComplexMessage.php index 58de213e9c..46142d1a08 100644 --- a/packages/intl/src/MessageFormat/Parser/Node/ComplexMessage.php +++ b/packages/intl/src/MessageFormat/Parser/Node/ComplexMessage.php @@ -3,6 +3,7 @@ namespace Tempest\Intl\MessageFormat\Parser\Node; use Tempest\Intl\MessageFormat\Parser\Node\ComplexBody\ComplexBody; +use Tempest\Intl\MessageFormat\Parser\Node\Declaration\Declaration; final class ComplexMessage extends MessageNode { diff --git a/packages/intl/src/MessageFormat/Parser/Node/Markup/Markup.php b/packages/intl/src/MessageFormat/Parser/Node/Markup/Markup.php index 938a85fe3b..d245c02231 100644 --- a/packages/intl/src/MessageFormat/Parser/Node/Markup/Markup.php +++ b/packages/intl/src/MessageFormat/Parser/Node/Markup/Markup.php @@ -2,14 +2,16 @@ namespace Tempest\Intl\MessageFormat\Parser\Node\Markup; +use Tempest\Intl\MessageFormat\Parser\Node\Expression\Attribute; +use Tempest\Intl\MessageFormat\Parser\Node\Expression\Option; use Tempest\Intl\MessageFormat\Parser\Node\Identifier; use Tempest\Intl\MessageFormat\Parser\Node\Pattern\Placeholder; final readonly class Markup implements Placeholder { /** - * @param (Option)[] $options - * @param (Attribute)[] $attributes + * @param Option[] $options + * @param Attribute[] $attributes */ public function __construct( public MarkupType $type, From 9f407ad484c64abedb8dcc7cd5a3a451e1f2fd56 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:35:20 +0100 Subject: [PATCH 107/134] fix(intl): resolve type issues in MessageFormatter --- .../Formatter/MessageFormatter.php | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php b/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php index 01dc72f0fe..c0acb0f83f 100644 --- a/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php +++ b/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php @@ -31,7 +31,6 @@ use Tempest\Intl\MessageFormat\Parser\Node\Variable; use Tempest\Intl\MessageFormat\SelectorFunction; use Tempest\Intl\MessageFormat\StandaloneMarkupFormatter; -use Tempest\Support\Arr; use function Tempest\Support\arr; @@ -102,18 +101,22 @@ private function formatMessage(MessageNode $message): string identifier: $variableName, value: $this->variables[$variableName]->value, selector: $this->getSelectorFunction((string) $expression->function->identifier), - formatter: $this->getFormattingFunction((string) $expression->function?->identifier), + formatter: $this->getFormattingFunction((string) $expression->function->identifier), parameters: $this->evaluateOptions($expression->function->options), ); } } elseif ($declaration instanceof LocalDeclaration) { $variableName = $declaration->variable->name->name; + $functionName = $declaration->expression->function + ? (string) $declaration->expression->function->identifier + : null; + $localVariables[$variableName] = new LocalVariable( identifier: $variableName, value: $this->evaluateExpression($declaration->expression)->value, - selector: $this->getSelectorFunction($declaration->expression->function?->identifier), - formatter: $this->getFormattingFunction($declaration->expression->function?->identifier), + selector: $this->getSelectorFunction($functionName), + formatter: $this->getFormattingFunction($functionName), parameters: $declaration->expression->attributes, ); } @@ -344,7 +347,7 @@ private function parseLocalVariables(array $variables): array private function formatMarkup(Markup $markup): string { $tag = (string) $markup->identifier; - $options = Arr\map_with_keys($markup->options, fn (Option $option) => yield $option->identifier->name => $option->value->value); + $options = $this->evaluateOptions($markup->options); if ($markup->type === MarkupType::STANDALONE) { if (is_null($formatter = $this->getStandaloneMarkupFormatter($tag))) { @@ -358,11 +361,11 @@ private function formatMarkup(Markup $markup): string return ''; } - return match ($markup->type) { - MarkupType::OPEN => $formatter->formatOpenTag($tag, $options), - MarkupType::CLOSE => $formatter->formatCloseTag($tag), - default => '', - }; + if ($markup->type === MarkupType::OPEN) { + return $formatter->formatOpenTag($tag, $options); + } + + return $formatter->formatCloseTag($tag); } private function getMarkupFormatter(?string $tag): ?MarkupFormatter From 12275b2303600f77751c2808102a90d5bf5cfc90 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:35:40 +0100 Subject: [PATCH 108/134] fix(intl): fix parser optional check and catalog type --- packages/intl/src/Catalog/GenericCatalog.php | 2 +- packages/intl/src/MessageFormat/Parser/MessageFormatParser.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/intl/src/Catalog/GenericCatalog.php b/packages/intl/src/Catalog/GenericCatalog.php index 01bdbc682d..34aa05d1f7 100644 --- a/packages/intl/src/Catalog/GenericCatalog.php +++ b/packages/intl/src/Catalog/GenericCatalog.php @@ -8,7 +8,7 @@ final class GenericCatalog implements Catalog { /** - * @var array $catalog + * @param array $catalog */ public function __construct( private array $catalog = [], diff --git a/packages/intl/src/MessageFormat/Parser/MessageFormatParser.php b/packages/intl/src/MessageFormat/Parser/MessageFormatParser.php index fa59c59280..95f288065e 100644 --- a/packages/intl/src/MessageFormat/Parser/MessageFormatParser.php +++ b/packages/intl/src/MessageFormat/Parser/MessageFormatParser.php @@ -129,7 +129,7 @@ private function parseInputDeclaration(): InputDeclaration $optional = (bool) array_find( array: $expression->function->options ?? [], - callback: fn (Option $option) => $option->identifier->name === 'default' && ! is_null($option->value->value), + callback: fn (Option $option) => $option->identifier->name === 'default', ); return new InputDeclaration($expression, $optional); From 120f196a8f81be3529d3501344bbea0210aa7e57 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:35:54 +0100 Subject: [PATCH 109/134] fix(intl): cast exponent to int for array key usage --- packages/intl/src/Number/functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/intl/src/Number/functions.php b/packages/intl/src/Number/functions.php index 3652c1cd25..e67d018634 100644 --- a/packages/intl/src/Number/functions.php +++ b/packages/intl/src/Number/functions.php @@ -172,7 +172,7 @@ function to_human_readable(int|float $number, int $precision = 0, ?int $maxPreci return sprintf('%s' . end($units), namespace\to_human_readable($number / 1e15, $precision, $maxPrecision, $units)); } - $numberExponent = Math\floor(Math\log($number, base: 10)); + $numberExponent = (int) Math\floor(Math\log($number, base: 10)); $displayExponent = $numberExponent - ($numberExponent % 3); $number /= 10 ** $displayExponent; From 0ebdf78bb0e7df606cd89e0e4f4de92dab64418d Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:36:13 +0100 Subject: [PATCH 110/134] fix(intl): fix tests to use correct AST types --- packages/intl/tests/FormatterTest.php | 4 +- packages/intl/tests/GenericTranslatorTest.php | 2 +- packages/intl/tests/ParserTest.php | 48 +++++++++++++++---- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/intl/tests/FormatterTest.php b/packages/intl/tests/FormatterTest.php index fce7c77d99..e7428c305c 100644 --- a/packages/intl/tests/FormatterTest.php +++ b/packages/intl/tests/FormatterTest.php @@ -420,8 +420,6 @@ private function createNumberFunction(): NumberFunction private function createDateTimeFunction(): DateTimeFunction { - return new DateTimeFunction( - new IntlConfig(Locale::default(), Locale::default()), - ); + return new DateTimeFunction(); } } diff --git a/packages/intl/tests/GenericTranslatorTest.php b/packages/intl/tests/GenericTranslatorTest.php index 2d04fc87d6..fcc76a9f8d 100644 --- a/packages/intl/tests/GenericTranslatorTest.php +++ b/packages/intl/tests/GenericTranslatorTest.php @@ -37,7 +37,7 @@ protected function setUp(): void formatter: new MessageFormatter([ new StringFunction(), new NumberFunction($this->config), - new DateTimeFunction($this->config), + new DateTimeFunction(), ]), ); } diff --git a/packages/intl/tests/ParserTest.php b/packages/intl/tests/ParserTest.php index 8a6eaf8d82..9860918397 100644 --- a/packages/intl/tests/ParserTest.php +++ b/packages/intl/tests/ParserTest.php @@ -4,9 +4,12 @@ use PHPUnit\Framework\TestCase; use Tempest\Intl\MessageFormat\Parser\MessageFormatParser; +use Tempest\Intl\MessageFormat\Parser\Node\ComplexBody\Matcher; use Tempest\Intl\MessageFormat\Parser\Node\ComplexMessage; +use Tempest\Intl\MessageFormat\Parser\Node\Declaration\InputDeclaration; use Tempest\Intl\MessageFormat\Parser\Node\Declaration\LocalDeclaration; use Tempest\Intl\MessageFormat\Parser\Node\Expression\VariableExpression; +use Tempest\Intl\MessageFormat\Parser\Node\Literal\Literal; use Tempest\Intl\MessageFormat\Parser\Node\Pattern\Pattern; use Tempest\Intl\MessageFormat\Parser\Node\Pattern\Text; use Tempest\Intl\MessageFormat\Parser\Node\SimpleMessage; @@ -35,11 +38,15 @@ public function test_local_declaration(): void $this->assertInstanceOf(Text::class, $ast->pattern->elements[0]); $this->assertInstanceOf(VariableExpression::class, $ast->pattern->elements[1]); $this->assertInstanceOf(LocalDeclaration::class, $ast->declarations[0]); + + $expression = $ast->declarations[0]->expression; + $this->assertInstanceOf(VariableExpression::class, $expression); + $this->assertSame('time', $ast->declarations[0]->variable->name->name); $this->assertSame('datetime', $ast->declarations[0]->expression->function->identifier->name); $this->assertSame('style', $ast->declarations[0]->expression->function->options[0]->identifier->name); $this->assertSame('medium', $ast->declarations[0]->expression->function->options[0]->value->value); - $this->assertSame('launch_date', $ast->declarations[0]->expression->variable->name->name); + $this->assertSame('launch_date', $expression->variable->name->name); } public function test_input_declaration(): void @@ -54,21 +61,44 @@ public function test_input_declaration(): void MF2)->parse(); $this->assertInstanceOf(ComplexMessage::class, $ast); - $this->assertSame('numDays', $ast->pattern->elements[0]->pattern->elements[0]->variable->name->name); - $this->assertSame(' one', $ast->pattern->elements[0]->pattern->elements[1]->value); - $this->assertSame('numDays', $ast->pattern->elements[1]->pattern->elements[0]->variable->name->name); - $this->assertSame(' two', $ast->pattern->elements[1]->pattern->elements[1]->value); - $this->assertSame('numDays', $ast->pattern->elements[2]->pattern->elements[0]->variable->name->name); - $this->assertSame(' three', $ast->pattern->elements[2]->pattern->elements[1]->value); + $this->assertInstanceOf(InputDeclaration::class, $ast->declarations[0]); + $this->assertSame('numDays', $ast->declarations[0]->expression->variable->name->name); + + $this->assertInstanceOf(Matcher::class, $ast->body); + + $firstVariant = $ast->body->variants[0]->pattern->pattern; + $this->assertInstanceOf(VariableExpression::class, $firstVariant->elements[0]); + $this->assertInstanceOf(Text::class, $firstVariant->elements[1]); + $this->assertSame('numDays', $firstVariant->elements[0]->variable->name->name); + $this->assertSame(' one', $firstVariant->elements[1]->value); + + $secondVariant = $ast->body->variants[1]->pattern->pattern; + $this->assertInstanceOf(VariableExpression::class, $secondVariant->elements[0]); + $this->assertInstanceOf(Text::class, $secondVariant->elements[1]); + $this->assertSame('numDays', $secondVariant->elements[0]->variable->name->name); + $this->assertSame(' two', $secondVariant->elements[1]->value); + + $thirdVariant = $ast->body->variants[2]->pattern->pattern; + $this->assertInstanceOf(VariableExpression::class, $thirdVariant->elements[0]); + $this->assertInstanceOf(Text::class, $thirdVariant->elements[1]); + $this->assertSame('numDays', $thirdVariant->elements[0]->variable->name->name); + $this->assertSame(' three', $thirdVariant->elements[1]->value); } public function test_function_with_option_quoted_literal(): void { - /** @var ComplexMessage $ast */ $ast = new MessageFormatParser(<<<'MF2' Today is {$today :datetime pattern=|yyyy/MM/dd|}. MF2)->parse(); - $this->assertSame('yyyy/MM/dd', $ast->pattern->elements[1]->function->options[0]->value->value); + $this->assertInstanceOf(SimpleMessage::class, $ast); + $this->assertInstanceOf(VariableExpression::class, $ast->pattern->elements[1]); + + $function = $ast->pattern->elements[1]->function; + $this->assertNotNull($function); + + $optionValue = $function->options[0]->value; + $this->assertInstanceOf(Literal::class, $optionValue); + $this->assertSame('yyyy/MM/dd', $optionValue->value); } } From 561d0c87cb10d3136a80d553bd1d0a89384fb566 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:36:33 +0100 Subject: [PATCH 111/134] fix(intl): generate type-safe plural rules matcher --- packages/intl/bin/plural-rules.php | 97 ++- .../src/PluralRules/PluralRulesMatcher.php | 674 ++++++++++-------- 2 files changed, 483 insertions(+), 288 deletions(-) diff --git a/packages/intl/bin/plural-rules.php b/packages/intl/bin/plural-rules.php index 19de138307..0a79e7795a 100755 --- a/packages/intl/bin/plural-rules.php +++ b/packages/intl/bin/plural-rules.php @@ -32,22 +32,26 @@ public function generate(): string $output .= ' * Generated on: ' . date('Y-m-d H:i:s') . "\n"; $output .= " */\n"; $output .= "final class {$this->className}\n{\n"; - $output .= $this->generateHelperMethods(); $pluralRules = $this->data['supplemental']['plurals-type-cardinal'] ?? []; + $languageMethods = ''; + foreach ($pluralRules as $locale => $rules) { - $output .= $this->generateLanguageMethod($locale, $rules); + $languageMethods .= $this->generateLanguageMethod($locale, $rules); } + $output .= $this->generateHelperMethods(str_contains($languageMethods, 'self::matchesValues(')); + $output .= $languageMethods; + $output .= $this->generateDispatcherMethod(array_keys($pluralRules)); $output .= "}\n"; return $output; } - private function generateHelperMethods(): string + private function generateHelperMethods(bool $includeMatchesValues): string { - return <<<'PHP' + $helpers = <<<'PHP' /** * Extracts the integer part of a number. */ @@ -63,7 +67,7 @@ private static function getVisibleFractionalDigits(float|int $n): int { $str = (string) $n; - if (!str_contains($str, '.')) { + if (! str_contains($str, '.')) { return 0; } @@ -77,7 +81,7 @@ private static function getFractionalDigits(float|int $n): int { $str = (string) $n; - if (!str_contains($str, '.')) { + if (! str_contains($str, '.')) { return 0; } @@ -126,6 +130,21 @@ private static function inRange(int|float $value, int|float $start, int|float $e return $value >= $start && $value <= $end; } + /** + * Checks whether two numeric values are equal. + */ + private static function isEqual(int|float $left, int|float $right): bool + { + return $left === $right; + } + + PHP; + + if (! $includeMatchesValues) { + return $helpers; + } + + return $helpers . <<<'PHP' /** * Checks if number matches any value in comma-separated list. */ @@ -152,6 +171,7 @@ private static function matchesValues(int|float $value, string $values): bool return true; } } + return false; } @@ -184,6 +204,8 @@ private function generateLanguageMethod(string $locale, array $rules): string } } + $excludedEqualities = []; + foreach ($sortedRules as $category => $rule) { if ($category === 'other') { $output .= " return '{$category}';\n"; @@ -191,9 +213,16 @@ private function generateLanguageMethod(string $locale, array $rules): string } if ($condition = $this->parseRule($rule)) { + $condition = $this->removeExcludedNotEquals($condition, $excludedEqualities); + $output .= " if ({$condition}) {\n"; $output .= " return '{$category}';\n"; $output .= " }\n\n"; + + $excludedEqualities = array_values(array_unique([ + ...$excludedEqualities, + ...$this->extractPureEquals($condition), + ])); } } @@ -202,6 +231,56 @@ private function generateLanguageMethod(string $locale, array $rules): string return $output; } + /** + * @return string[] + */ + private function extractPureEquals(string $condition): array + { + if (str_contains($condition, '&&')) { + return []; + } + + preg_match_all('/self::isEqual\(([^,]+),\s*(\d+(?:\.\d+)?)\)/', $condition, $matches, PREG_SET_ORDER); + + if ($matches === []) { + return []; + } + + $stripped = $condition; + $equalities = []; + + foreach ($matches as $match) { + $stripped = str_replace($match[0], '', $stripped); + $equalities[] = 'self::isEqual(' . trim($match[1]) . ", {$match[2]})"; + } + + $stripped = preg_replace('/[()\s]/', '', $stripped); + $stripped = str_replace('||', '', $stripped); + + if ($stripped !== '') { + return []; + } + + return $equalities; + } + + /** + * @param string[] $excludedEqualities + */ + private function removeExcludedNotEquals(string $condition, array $excludedEqualities): string + { + foreach ($excludedEqualities as $equality) { + $negatedEquality = preg_quote("!{$equality}", '/'); + + $condition = preg_replace('/\(\s*' . $negatedEquality . '\s*\)\s*&&\s*/', '', $condition) ?? $condition; + $condition = preg_replace('/\s*&&\s*\(\s*' . $negatedEquality . '\s*\)/', '', $condition) ?? $condition; + $condition = preg_replace('/' . $negatedEquality . '\s*&&\s*/', '', $condition) ?? $condition; + $condition = preg_replace('/\s*&&\s*' . $negatedEquality . '/', '', $condition) ?? $condition; + } + + return $condition; + } + private function parseRule(string $rule): string { $rulePart = trim(explode('@', $rule)[0]); @@ -262,7 +341,9 @@ private function parseValueCondition(string $varExpression, string $operator, st $isNegative = $operator === '!=='; if (preg_match('/^\d+(?:\.\d+)?$/', $values)) { - return "{$varExpression} {$operator} {$values}"; + $condition = "self::isEqual({$varExpression}, {$values})"; + + return $isNegative ? "!{$condition}" : $condition; } if (preg_match('/^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)$/', $values, $matches)) { @@ -290,7 +371,7 @@ private function parseValueCondition(string $varExpression, string $operator, st $conditions[] = "self::inRange({$varExpression}, {$start}, {$end})"; } } else { - $conditions[] = "{$varExpression} === {$part}"; + $conditions[] = "self::isEqual({$varExpression}, {$part})"; } } diff --git a/packages/intl/src/PluralRules/PluralRulesMatcher.php b/packages/intl/src/PluralRules/PluralRulesMatcher.php index 09e52266b7..eb89ced577 100644 --- a/packages/intl/src/PluralRules/PluralRulesMatcher.php +++ b/packages/intl/src/PluralRules/PluralRulesMatcher.php @@ -6,7 +6,7 @@ /** * This file was auto-generated using the plural rules CLDR dataset. - * Generated on: 2025-06-21 14:21:38 + * Generated on: 2026-02-15 08:37:06 */ final class PluralRulesMatcher { @@ -89,32 +89,11 @@ private static function inRange(int|float $value, int|float $start, int|float $e } /** - * Checks if number matches any value in comma-separated list. + * Checks whether two numeric values are equal. */ - private static function matchesValues(int|float $value, string $values): bool + private static function isEqual(int|float $left, int|float $right): bool { - $parts = explode(',', $values); - - foreach ($parts as $part) { - $part = trim($part); - - if (str_contains($part, '~')) { - [$start, $end] = explode('~', $part); - - if (self::inRange($value, (float) trim($start), (float) trim($end))) { - return true; - } - } elseif (str_contains($part, '..')) { - [$start, $end] = explode('..', $part); - - if (self::inRange($value, (float) trim($start), (float) trim($end))) { - return true; - } - } elseif ((float) $part === (float) $value) { - return true; - } - } - return false; + return $left === $right; } /** @@ -128,7 +107,7 @@ private static function getPluralCategoryAf(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -164,7 +143,7 @@ private static function getPluralCategoryAm(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -182,7 +161,7 @@ private static function getPluralCategoryAn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -200,15 +179,15 @@ private static function getPluralCategoryAr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0) { + if (self::isEqual($n, 0)) { return 'zero'; } - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -234,15 +213,15 @@ private static function getPluralCategoryArs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0) { + if (self::isEqual($n, 0)) { return 'zero'; } - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -268,7 +247,7 @@ private static function getPluralCategoryAs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -286,7 +265,7 @@ private static function getPluralCategoryAsa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -304,7 +283,7 @@ private static function getPluralCategoryAst(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -322,7 +301,7 @@ private static function getPluralCategoryAz(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -340,7 +319,7 @@ private static function getPluralCategoryBal(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -358,7 +337,7 @@ private static function getPluralCategoryBe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if (($n % 10) === 1 && ($n % 100) !== 11) { + if (self::isEqual($n % 10, 1) && ! self::isEqual($n % 100, 11)) { return 'one'; } @@ -366,7 +345,7 @@ private static function getPluralCategoryBe(float|int $n): string return 'few'; } - if (($n % 10) === 0 || self::inRange($n % 10, 5, 9) || self::inRange($n % 100, 11, 14)) { + if (self::isEqual($n % 10, 0) || self::inRange($n % 10, 5, 9) || self::inRange($n % 100, 11, 14)) { return 'many'; } @@ -384,7 +363,7 @@ private static function getPluralCategoryBem(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -402,7 +381,7 @@ private static function getPluralCategoryBez(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -420,7 +399,7 @@ private static function getPluralCategoryBg(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -456,11 +435,11 @@ private static function getPluralCategoryBlo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0) { + if (self::isEqual($n, 0)) { return 'zero'; } - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -492,7 +471,7 @@ private static function getPluralCategoryBn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -524,19 +503,22 @@ private static function getPluralCategoryBr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if (($n % 10) === 1 && ! (($n % 100) === 11 || ($n % 100) === 71 || ($n % 100) === 91)) { + if (self::isEqual($n % 10, 1) && ! (self::isEqual($n % 100, 11) || self::isEqual($n % 100, 71) || self::isEqual($n % 100, 91))) { return 'one'; } - if (($n % 10) === 2 && ! (($n % 100) === 12 || ($n % 100) === 72 || ($n % 100) === 92)) { + if (self::isEqual($n % 10, 2) && ! (self::isEqual($n % 100, 12) || self::isEqual($n % 100, 72) || self::isEqual($n % 100, 92))) { return 'two'; } - if ((self::inRange($n % 10, 3, 4) || ($n % 10) === 9) && ! (self::inRange($n % 100, 10, 19) || self::inRange($n % 100, 70, 79) || self::inRange($n % 100, 90, 99))) { + if ( + (self::inRange($n % 10, 3, 4) || self::isEqual($n % 10, 9)) + && ! (self::inRange($n % 100, 10, 19) || self::inRange($n % 100, 70, 79) || self::inRange($n % 100, 90, 99)) + ) { return 'few'; } - if ($n !== 0 && ($n % 1000000) === 0) { + if (! self::isEqual($n, 0) && self::isEqual($n % 1000000, 0)) { return 'many'; } @@ -554,7 +536,7 @@ private static function getPluralCategoryBrx(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -572,11 +554,11 @@ private static function getPluralCategoryBs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1 && ($i % 100) !== 11 || ($f % 10) === 1 && ($f % 100) !== 11) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11) || self::isEqual($f % 10, 1) && ! self::isEqual($f % 100, 11)) { return 'one'; } - if ($v === 0 && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { + if (self::isEqual($v, 0) && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { return 'few'; } @@ -594,11 +576,11 @@ private static function getPluralCategoryCa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -616,7 +598,7 @@ private static function getPluralCategoryCe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -635,9 +617,9 @@ private static function getPluralCategoryCeb(float|int $n): string $e = self::getExponent($n); if ( - $v === 0 && ($i === 1 || $i === 2 || $i === 3) - || $v === 0 && ! (($i % 10) === 4 || ($i % 10) === 6 || ($i % 10) === 9) - || $v !== 0 && ! (($f % 10) === 4 || ($f % 10) === 6 || ($f % 10) === 9) + self::isEqual($v, 0) && (self::isEqual($i, 1) || self::isEqual($i, 2) || self::isEqual($i, 3)) + || self::isEqual($v, 0) && ! (self::isEqual($i % 10, 4) || self::isEqual($i % 10, 6) || self::isEqual($i % 10, 9)) + || ! self::isEqual($v, 0) && ! (self::isEqual($f % 10, 4) || self::isEqual($f % 10, 6) || self::isEqual($f % 10, 9)) ) { return 'one'; } @@ -656,7 +638,7 @@ private static function getPluralCategoryCgg(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -674,7 +656,7 @@ private static function getPluralCategoryChr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -692,7 +674,7 @@ private static function getPluralCategoryCkb(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -710,15 +692,15 @@ private static function getPluralCategoryCs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if (self::inRange($i, 2, 4) && $v === 0) { + if (self::inRange($i, 2, 4) && self::isEqual($v, 0)) { return 'few'; } - if ($v !== 0) { + if (! self::isEqual($v, 0)) { return 'many'; } @@ -743,6 +725,28 @@ private static function getPluralCategoryCsw(float|int $n): string return 'other'; } + /** + * Gets the plural category for the cv locale. + */ + private static function getPluralCategoryCv(float|int $n): string + { + $i = self::getIntegerPart($n); + $v = self::getVisibleFractionalDigits($n); + $f = self::getFractionalDigits($n); + $t = self::getCompactExponent($n); + $e = self::getExponent($n); + + if (self::isEqual($n, 0)) { + return 'zero'; + } + + if (self::isEqual($n, 1)) { + return 'one'; + } + + return 'other'; + } + /** * Gets the plural category for the cy locale. */ @@ -754,23 +758,23 @@ private static function getPluralCategoryCy(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0) { + if (self::isEqual($n, 0)) { return 'zero'; } - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } - if ($n === 3) { + if (self::isEqual($n, 3)) { return 'few'; } - if ($n === 6) { + if (self::isEqual($n, 6)) { return 'many'; } @@ -788,7 +792,7 @@ private static function getPluralCategoryDa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1 || $t !== 0 && ($i === 0 || $i === 1)) { + if (self::isEqual($n, 1) || ! self::isEqual($t, 0) && (self::isEqual($i, 0) || self::isEqual($i, 1))) { return 'one'; } @@ -806,7 +810,7 @@ private static function getPluralCategoryDe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -824,7 +828,7 @@ private static function getPluralCategoryDoi(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -842,15 +846,15 @@ private static function getPluralCategoryDsb(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 100) === 1 || ($f % 100) === 1) { + if (self::isEqual($v, 0) && self::isEqual($i % 100, 1) || self::isEqual($f % 100, 1)) { return 'one'; } - if ($v === 0 && ($i % 100) === 2 || ($f % 100) === 2) { + if (self::isEqual($v, 0) && self::isEqual($i % 100, 2) || self::isEqual($f % 100, 2)) { return 'two'; } - if ($v === 0 && self::inRange($i % 100, 3, 4) || self::inRange($f % 100, 3, 4)) { + if (self::isEqual($v, 0) && self::inRange($i % 100, 3, 4) || self::inRange($f % 100, 3, 4)) { return 'few'; } @@ -868,7 +872,7 @@ private static function getPluralCategoryDv(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -900,7 +904,7 @@ private static function getPluralCategoryEe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -918,7 +922,7 @@ private static function getPluralCategoryEl(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -936,7 +940,7 @@ private static function getPluralCategoryEn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -954,7 +958,7 @@ private static function getPluralCategoryEo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -972,11 +976,11 @@ private static function getPluralCategoryEs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -994,7 +998,7 @@ private static function getPluralCategoryEt(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -1012,7 +1016,7 @@ private static function getPluralCategoryEu(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1030,7 +1034,7 @@ private static function getPluralCategoryFa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -1048,7 +1052,7 @@ private static function getPluralCategoryFf(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $i === 1) { + if (self::isEqual($i, 0) || self::isEqual($i, 1)) { return 'one'; } @@ -1066,7 +1070,7 @@ private static function getPluralCategoryFi(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -1085,9 +1089,9 @@ private static function getPluralCategoryFil(float|int $n): string $e = self::getExponent($n); if ( - $v === 0 && ($i === 1 || $i === 2 || $i === 3) - || $v === 0 && ! (($i % 10) === 4 || ($i % 10) === 6 || ($i % 10) === 9) - || $v !== 0 && ! (($f % 10) === 4 || ($f % 10) === 6 || ($f % 10) === 9) + self::isEqual($v, 0) && (self::isEqual($i, 1) || self::isEqual($i, 2) || self::isEqual($i, 3)) + || self::isEqual($v, 0) && ! (self::isEqual($i % 10, 4) || self::isEqual($i % 10, 6) || self::isEqual($i % 10, 9)) + || ! self::isEqual($v, 0) && ! (self::isEqual($f % 10, 4) || self::isEqual($f % 10, 6) || self::isEqual($f % 10, 9)) ) { return 'one'; } @@ -1106,7 +1110,7 @@ private static function getPluralCategoryFo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1124,11 +1128,11 @@ private static function getPluralCategoryFr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $i === 1) { + if (self::isEqual($i, 0) || self::isEqual($i, 1)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -1146,7 +1150,7 @@ private static function getPluralCategoryFur(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1164,7 +1168,7 @@ private static function getPluralCategoryFy(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -1182,11 +1186,11 @@ private static function getPluralCategoryGa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -1212,11 +1216,11 @@ private static function getPluralCategoryGd(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1 || $n === 11) { + if (self::isEqual($n, 1) || self::isEqual($n, 11)) { return 'one'; } - if ($n === 2 || $n === 12) { + if (self::isEqual($n, 2) || self::isEqual($n, 12)) { return 'two'; } @@ -1238,7 +1242,7 @@ private static function getPluralCategoryGl(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -1256,7 +1260,7 @@ private static function getPluralCategoryGsw(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1274,7 +1278,7 @@ private static function getPluralCategoryGu(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -1310,19 +1314,22 @@ private static function getPluralCategoryGv(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1)) { return 'one'; } - if ($v === 0 && ($i % 10) === 2) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 2)) { return 'two'; } - if ($v === 0 && (($i % 100) === 0 || ($i % 100) === 20 || ($i % 100) === 40 || ($i % 100) === 60 || ($i % 100) === 80)) { + if ( + self::isEqual($v, 0) + && (self::isEqual($i % 100, 0) || self::isEqual($i % 100, 20) || self::isEqual($i % 100, 40) || self::isEqual($i % 100, 60) || self::isEqual($i % 100, 80)) + ) { return 'few'; } - if ($v !== 0) { + if (! self::isEqual($v, 0)) { return 'many'; } @@ -1340,7 +1347,7 @@ private static function getPluralCategoryHa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1358,7 +1365,7 @@ private static function getPluralCategoryHaw(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1376,11 +1383,11 @@ private static function getPluralCategoryHe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0 || $i === 0 && $v !== 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0) || self::isEqual($i, 0) && ! self::isEqual($v, 0)) { return 'one'; } - if ($i === 2 && $v === 0) { + if (self::isEqual($i, 2) && self::isEqual($v, 0)) { return 'two'; } @@ -1398,7 +1405,7 @@ private static function getPluralCategoryHi(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -1430,11 +1437,11 @@ private static function getPluralCategoryHr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1 && ($i % 100) !== 11 || ($f % 10) === 1 && ($f % 100) !== 11) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11) || self::isEqual($f % 10, 1) && ! self::isEqual($f % 100, 11)) { return 'one'; } - if ($v === 0 && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { + if (self::isEqual($v, 0) && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { return 'few'; } @@ -1452,15 +1459,15 @@ private static function getPluralCategoryHsb(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 100) === 1 || ($f % 100) === 1) { + if (self::isEqual($v, 0) && self::isEqual($i % 100, 1) || self::isEqual($f % 100, 1)) { return 'one'; } - if ($v === 0 && ($i % 100) === 2 || ($f % 100) === 2) { + if (self::isEqual($v, 0) && self::isEqual($i % 100, 2) || self::isEqual($f % 100, 2)) { return 'two'; } - if ($v === 0 && self::inRange($i % 100, 3, 4) || self::inRange($f % 100, 3, 4)) { + if (self::isEqual($v, 0) && self::inRange($i % 100, 3, 4) || self::inRange($f % 100, 3, 4)) { return 'few'; } @@ -1478,7 +1485,7 @@ private static function getPluralCategoryHu(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1496,7 +1503,7 @@ private static function getPluralCategoryHy(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $i === 1) { + if (self::isEqual($i, 0) || self::isEqual($i, 1)) { return 'one'; } @@ -1514,7 +1521,7 @@ private static function getPluralCategoryIa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -1535,6 +1542,24 @@ private static function getPluralCategoryId(float|int $n): string return 'other'; } + /** + * Gets the plural category for the ie locale. + */ + private static function getPluralCategoryIe(float|int $n): string + { + $i = self::getIntegerPart($n); + $v = self::getVisibleFractionalDigits($n); + $f = self::getFractionalDigits($n); + $t = self::getCompactExponent($n); + $e = self::getExponent($n); + + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { + return 'one'; + } + + return 'other'; + } + /** * Gets the plural category for the ig locale. */ @@ -1574,7 +1599,7 @@ private static function getPluralCategoryIo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -1592,7 +1617,7 @@ private static function getPluralCategoryIs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($t === 0 && ($i % 10) === 1 && ($i % 100) !== 11 || ($t % 10) === 1 && ($t % 100) !== 11) { + if (self::isEqual($t, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11) || self::isEqual($t % 10, 1) && ! self::isEqual($t % 100, 11)) { return 'one'; } @@ -1610,11 +1635,11 @@ private static function getPluralCategoryIt(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -1632,11 +1657,11 @@ private static function getPluralCategoryIu(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -1682,7 +1707,7 @@ private static function getPluralCategoryJgo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1700,7 +1725,7 @@ private static function getPluralCategoryJmc(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1746,7 +1771,7 @@ private static function getPluralCategoryKa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1764,7 +1789,7 @@ private static function getPluralCategoryKab(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $i === 1) { + if (self::isEqual($i, 0) || self::isEqual($i, 1)) { return 'one'; } @@ -1782,7 +1807,7 @@ private static function getPluralCategoryKaj(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1800,7 +1825,7 @@ private static function getPluralCategoryKcg(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1846,7 +1871,7 @@ private static function getPluralCategoryKk(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1864,7 +1889,7 @@ private static function getPluralCategoryKkj(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1882,7 +1907,7 @@ private static function getPluralCategoryKl(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1914,7 +1939,7 @@ private static function getPluralCategoryKn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -1935,6 +1960,42 @@ private static function getPluralCategoryKo(float|int $n): string return 'other'; } + /** + * Gets the plural category for the kok locale. + */ + private static function getPluralCategoryKok(float|int $n): string + { + $i = self::getIntegerPart($n); + $v = self::getVisibleFractionalDigits($n); + $f = self::getFractionalDigits($n); + $t = self::getCompactExponent($n); + $e = self::getExponent($n); + + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { + return 'one'; + } + + return 'other'; + } + + /** + * Gets the plural category for the kok-Latn locale. + */ + private static function getPluralCategoryKok_Latn(float|int $n): string + { + $i = self::getIntegerPart($n); + $v = self::getVisibleFractionalDigits($n); + $f = self::getFractionalDigits($n); + $t = self::getCompactExponent($n); + $e = self::getExponent($n); + + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { + return 'one'; + } + + return 'other'; + } + /** * Gets the plural category for the ks locale. */ @@ -1946,7 +2007,7 @@ private static function getPluralCategoryKs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1964,7 +2025,7 @@ private static function getPluralCategoryKsb(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -1982,11 +2043,11 @@ private static function getPluralCategoryKsh(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0) { + if (self::isEqual($n, 0)) { return 'zero'; } - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2004,7 +2065,7 @@ private static function getPluralCategoryKu(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2022,31 +2083,32 @@ private static function getPluralCategoryKw(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0) { + if (self::isEqual($n, 0)) { return 'zero'; } - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } if ( - ($n % 100) === 2 - || ($n % 100) === 22 - || ($n % 100) === 42 - || ($n % 100) === 62 - || ($n % 100) === 82 - || ($n % 1000) === 0 && (self::inRange($n % 100000, 1000, 20000) || ($n % 100000) === 40000 || ($n % 100000) === 60000 || ($n % 100000) === 80000) - || $n !== 0 && ($n % 1000000) === 100000 + self::isEqual($n % 100, 2) + || self::isEqual($n % 100, 22) + || self::isEqual($n % 100, 42) + || self::isEqual($n % 100, 62) + || self::isEqual($n % 100, 82) + || self::isEqual($n % 1000, 0) + && (self::inRange($n % 100000, 1000, 20000) || self::isEqual($n % 100000, 40000) || self::isEqual($n % 100000, 60000) || self::isEqual($n % 100000, 80000)) + || self::isEqual($n % 1000000, 100000) ) { return 'two'; } - if (($n % 100) === 3 || ($n % 100) === 23 || ($n % 100) === 43 || ($n % 100) === 63 || ($n % 100) === 83) { + if (self::isEqual($n % 100, 3) || self::isEqual($n % 100, 23) || self::isEqual($n % 100, 43) || self::isEqual($n % 100, 63) || self::isEqual($n % 100, 83)) { return 'few'; } - if ($n !== 1 && (($n % 100) === 1 || ($n % 100) === 21 || ($n % 100) === 41 || ($n % 100) === 61 || ($n % 100) === 81)) { + if (self::isEqual($n % 100, 1) || self::isEqual($n % 100, 21) || self::isEqual($n % 100, 41) || self::isEqual($n % 100, 61) || self::isEqual($n % 100, 81)) { return 'many'; } @@ -2064,7 +2126,7 @@ private static function getPluralCategoryKy(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2082,11 +2144,11 @@ private static function getPluralCategoryLag(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0) { + if (self::isEqual($n, 0)) { return 'zero'; } - if (($i === 0 || $i === 1) && $n !== 0) { + if (self::isEqual($i, 0) || self::isEqual($i, 1)) { return 'one'; } @@ -2104,7 +2166,7 @@ private static function getPluralCategoryLb(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2122,7 +2184,7 @@ private static function getPluralCategoryLg(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2140,7 +2202,7 @@ private static function getPluralCategoryLij(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -2172,11 +2234,11 @@ private static function getPluralCategoryLld(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -2226,7 +2288,7 @@ private static function getPluralCategoryLt(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if (($n % 10) === 1 && ! self::inRange($n % 100, 11, 19)) { + if (self::isEqual($n % 10, 1) && ! self::inRange($n % 100, 11, 19)) { return 'one'; } @@ -2234,7 +2296,7 @@ private static function getPluralCategoryLt(float|int $n): string return 'few'; } - if ($f !== 0) { + if (! self::isEqual($f, 0)) { return 'many'; } @@ -2252,11 +2314,15 @@ private static function getPluralCategoryLv(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if (($n % 10) === 0 || self::inRange($n % 100, 11, 19) || $v === 2 && self::inRange($f % 100, 11, 19)) { + if (self::isEqual($n % 10, 0) || self::inRange($n % 100, 11, 19) || self::isEqual($v, 2) && self::inRange($f % 100, 11, 19)) { return 'zero'; } - if (($n % 10) === 1 && ($n % 100) !== 11 || $v === 2 && ($f % 10) === 1 && ($f % 100) !== 11 || $v !== 2 && ($f % 10) === 1) { + if ( + self::isEqual($n % 10, 1) && ! self::isEqual($n % 100, 11) + || self::isEqual($v, 2) && self::isEqual($f % 10, 1) && ! self::isEqual($f % 100, 11) + || ! self::isEqual($v, 2) && self::isEqual($f % 10, 1) + ) { return 'one'; } @@ -2274,7 +2340,7 @@ private static function getPluralCategoryMas(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2310,7 +2376,7 @@ private static function getPluralCategoryMgo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2328,7 +2394,7 @@ private static function getPluralCategoryMk(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1 && ($i % 100) !== 11 || ($f % 10) === 1 && ($f % 100) !== 11) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11) || self::isEqual($f % 10, 1) && ! self::isEqual($f % 100, 11)) { return 'one'; } @@ -2346,7 +2412,7 @@ private static function getPluralCategoryMl(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2364,7 +2430,7 @@ private static function getPluralCategoryMn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2382,11 +2448,11 @@ private static function getPluralCategoryMo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($v !== 0 || $n === 0 || $n !== 1 && self::inRange($n % 100, 1, 19)) { + if (! self::isEqual($v, 0) || self::isEqual($n, 0) || ! self::isEqual($n, 1) && self::inRange($n % 100, 1, 19)) { return 'few'; } @@ -2404,7 +2470,7 @@ private static function getPluralCategoryMr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2436,15 +2502,15 @@ private static function getPluralCategoryMt(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } - if ($n === 0 || self::inRange($n % 100, 3, 10)) { + if (self::isEqual($n, 0) || self::inRange($n % 100, 3, 10)) { return 'few'; } @@ -2480,7 +2546,7 @@ private static function getPluralCategoryNah(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2498,11 +2564,11 @@ private static function getPluralCategoryNaq(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -2520,7 +2586,7 @@ private static function getPluralCategoryNb(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2538,7 +2604,7 @@ private static function getPluralCategoryNd(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2556,7 +2622,7 @@ private static function getPluralCategoryNe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2574,7 +2640,7 @@ private static function getPluralCategoryNl(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -2592,7 +2658,7 @@ private static function getPluralCategoryNn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2610,7 +2676,7 @@ private static function getPluralCategoryNnh(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2628,7 +2694,7 @@ private static function getPluralCategoryNo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2660,7 +2726,7 @@ private static function getPluralCategoryNr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2696,7 +2762,7 @@ private static function getPluralCategoryNy(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2714,7 +2780,7 @@ private static function getPluralCategoryNyn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2732,7 +2798,7 @@ private static function getPluralCategoryOm(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2750,7 +2816,7 @@ private static function getPluralCategoryOr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2768,7 +2834,7 @@ private static function getPluralCategoryOs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2818,7 +2884,7 @@ private static function getPluralCategoryPap(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2836,7 +2902,7 @@ private static function getPluralCategoryPcm(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -2854,15 +2920,19 @@ private static function getPluralCategoryPl(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($v === 0 && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14)) { + if (self::isEqual($v, 0) && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14)) { return 'few'; } - if ($v === 0 && $i !== 1 && self::inRange($i % 10, 0, 1) || $v === 0 && self::inRange($i % 10, 5, 9) || $v === 0 && self::inRange($i % 100, 12, 14)) { + if ( + self::isEqual($v, 0) && ! self::isEqual($i, 1) && self::inRange($i % 10, 0, 1) + || self::isEqual($v, 0) && self::inRange($i % 10, 5, 9) + || self::isEqual($v, 0) && self::inRange($i % 100, 12, 14) + ) { return 'many'; } @@ -2880,11 +2950,15 @@ private static function getPluralCategoryPrg(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if (($n % 10) === 0 || self::inRange($n % 100, 11, 19) || $v === 2 && self::inRange($f % 100, 11, 19)) { + if (self::isEqual($n % 10, 0) || self::inRange($n % 100, 11, 19) || self::isEqual($v, 2) && self::inRange($f % 100, 11, 19)) { return 'zero'; } - if (($n % 10) === 1 && ($n % 100) !== 11 || $v === 2 && ($f % 10) === 1 && ($f % 100) !== 11 || $v !== 2 && ($f % 10) === 1) { + if ( + self::isEqual($n % 10, 1) && ! self::isEqual($n % 100, 11) + || self::isEqual($v, 2) && self::isEqual($f % 10, 1) && ! self::isEqual($f % 100, 11) + || ! self::isEqual($v, 2) && self::isEqual($f % 10, 1) + ) { return 'one'; } @@ -2902,7 +2976,7 @@ private static function getPluralCategoryPs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2924,7 +2998,7 @@ private static function getPluralCategoryPt(float|int $n): string return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -2942,11 +3016,11 @@ private static function getPluralCategoryPt_PT(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -2964,7 +3038,7 @@ private static function getPluralCategoryRm(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -2982,11 +3056,11 @@ private static function getPluralCategoryRo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($v !== 0 || $n === 0 || $n !== 1 && self::inRange($n % 100, 1, 19)) { + if (! self::isEqual($v, 0) || self::isEqual($n, 0) || ! self::isEqual($n, 1) && self::inRange($n % 100, 1, 19)) { return 'few'; } @@ -3004,7 +3078,7 @@ private static function getPluralCategoryRof(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3022,15 +3096,15 @@ private static function getPluralCategoryRu(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1 && ($i % 100) !== 11) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11)) { return 'one'; } - if ($v === 0 && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14)) { + if (self::isEqual($v, 0) && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14)) { return 'few'; } - if ($v === 0 && ($i % 10) === 0 || $v === 0 && self::inRange($i % 10, 5, 9) || $v === 0 && self::inRange($i % 100, 11, 14)) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 0) || self::isEqual($v, 0) && self::inRange($i % 10, 5, 9) || self::isEqual($v, 0) && self::inRange($i % 100, 11, 14)) { return 'many'; } @@ -3048,7 +3122,7 @@ private static function getPluralCategoryRwk(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3080,7 +3154,7 @@ private static function getPluralCategorySaq(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3098,11 +3172,11 @@ private static function getPluralCategorySat(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -3120,7 +3194,7 @@ private static function getPluralCategorySc(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -3138,11 +3212,11 @@ private static function getPluralCategoryScn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -3160,7 +3234,7 @@ private static function getPluralCategorySd(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3178,7 +3252,7 @@ private static function getPluralCategorySdh(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3196,11 +3270,11 @@ private static function getPluralCategorySe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -3218,7 +3292,7 @@ private static function getPluralCategorySeh(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3253,6 +3327,36 @@ private static function getPluralCategorySg(float|int $n): string return 'other'; } + /** + * Gets the plural category for the sgs locale. + */ + private static function getPluralCategorySgs(float|int $n): string + { + $i = self::getIntegerPart($n); + $v = self::getVisibleFractionalDigits($n); + $f = self::getFractionalDigits($n); + $t = self::getCompactExponent($n); + $e = self::getExponent($n); + + if (self::isEqual($n % 10, 1) && ! self::isEqual($n % 100, 11)) { + return 'one'; + } + + if (self::isEqual($n, 2)) { + return 'two'; + } + + if (self::inRange($n % 10, 2, 9) && ! self::inRange($n % 100, 11, 19)) { + return 'few'; + } + + if (! self::isEqual($f, 0)) { + return 'many'; + } + + return 'other'; + } + /** * Gets the plural category for the sh locale. */ @@ -3264,11 +3368,11 @@ private static function getPluralCategorySh(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1 && ($i % 100) !== 11 || ($f % 10) === 1 && ($f % 100) !== 11) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11) || self::isEqual($f % 10, 1) && ! self::isEqual($f % 100, 11)) { return 'one'; } - if ($v === 0 && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { + if (self::isEqual($v, 0) && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { return 'few'; } @@ -3286,7 +3390,7 @@ private static function getPluralCategoryShi(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -3308,7 +3412,7 @@ private static function getPluralCategorySi(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 0 || $n === 1 || $i === 0 && $f === 1) { + if (self::isEqual($n, 0) || self::isEqual($n, 1) || self::isEqual($i, 0) && self::isEqual($f, 1)) { return 'one'; } @@ -3326,15 +3430,15 @@ private static function getPluralCategorySk(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if (self::inRange($i, 2, 4) && $v === 0) { + if (self::inRange($i, 2, 4) && self::isEqual($v, 0)) { return 'few'; } - if ($v !== 0) { + if (! self::isEqual($v, 0)) { return 'many'; } @@ -3352,15 +3456,15 @@ private static function getPluralCategorySl(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 100) === 1) { + if (self::isEqual($v, 0) && self::isEqual($i % 100, 1)) { return 'one'; } - if ($v === 0 && ($i % 100) === 2) { + if (self::isEqual($v, 0) && self::isEqual($i % 100, 2)) { return 'two'; } - if ($v === 0 && self::inRange($i % 100, 3, 4) || $v !== 0) { + if (self::isEqual($v, 0) && self::inRange($i % 100, 3, 4) || ! self::isEqual($v, 0)) { return 'few'; } @@ -3378,11 +3482,11 @@ private static function getPluralCategorySma(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -3400,11 +3504,11 @@ private static function getPluralCategorySmi(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -3422,11 +3526,11 @@ private static function getPluralCategorySmj(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -3444,11 +3548,11 @@ private static function getPluralCategorySmn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -3466,11 +3570,11 @@ private static function getPluralCategorySms(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } - if ($n === 2) { + if (self::isEqual($n, 2)) { return 'two'; } @@ -3488,7 +3592,7 @@ private static function getPluralCategorySn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3506,7 +3610,7 @@ private static function getPluralCategorySo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3524,7 +3628,7 @@ private static function getPluralCategorySq(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3542,11 +3646,11 @@ private static function getPluralCategorySr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1 && ($i % 100) !== 11 || ($f % 10) === 1 && ($f % 100) !== 11) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11) || self::isEqual($f % 10, 1) && ! self::isEqual($f % 100, 11)) { return 'one'; } - if ($v === 0 && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { + if (self::isEqual($v, 0) && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14) || self::inRange($f % 10, 2, 4) && ! self::inRange($f % 100, 12, 14)) { return 'few'; } @@ -3564,7 +3668,7 @@ private static function getPluralCategorySs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3582,7 +3686,7 @@ private static function getPluralCategorySsy(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3600,7 +3704,7 @@ private static function getPluralCategorySt(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3632,7 +3736,7 @@ private static function getPluralCategorySv(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -3650,7 +3754,7 @@ private static function getPluralCategorySw(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -3668,7 +3772,7 @@ private static function getPluralCategorySyr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3686,7 +3790,7 @@ private static function getPluralCategoryTa(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3704,7 +3808,7 @@ private static function getPluralCategoryTe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3722,7 +3826,7 @@ private static function getPluralCategoryTeo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3772,7 +3876,7 @@ private static function getPluralCategoryTig(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3790,7 +3894,7 @@ private static function getPluralCategoryTk(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3809,9 +3913,9 @@ private static function getPluralCategoryTl(float|int $n): string $e = self::getExponent($n); if ( - $v === 0 && ($i === 1 || $i === 2 || $i === 3) - || $v === 0 && ! (($i % 10) === 4 || ($i % 10) === 6 || ($i % 10) === 9) - || $v !== 0 && ! (($f % 10) === 4 || ($f % 10) === 6 || ($f % 10) === 9) + self::isEqual($v, 0) && (self::isEqual($i, 1) || self::isEqual($i, 2) || self::isEqual($i, 3)) + || self::isEqual($v, 0) && ! (self::isEqual($i % 10, 4) || self::isEqual($i % 10, 6) || self::isEqual($i % 10, 9)) + || ! self::isEqual($v, 0) && ! (self::isEqual($f % 10, 4) || self::isEqual($f % 10, 6) || self::isEqual($f % 10, 9)) ) { return 'one'; } @@ -3830,7 +3934,7 @@ private static function getPluralCategoryTn(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3876,7 +3980,7 @@ private static function getPluralCategoryTr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3894,7 +3998,7 @@ private static function getPluralCategoryTs(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3930,7 +4034,7 @@ private static function getPluralCategoryUg(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -3948,15 +4052,15 @@ private static function getPluralCategoryUk(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($v === 0 && ($i % 10) === 1 && ($i % 100) !== 11) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 1) && ! self::isEqual($i % 100, 11)) { return 'one'; } - if ($v === 0 && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14)) { + if (self::isEqual($v, 0) && self::inRange($i % 10, 2, 4) && ! self::inRange($i % 100, 12, 14)) { return 'few'; } - if ($v === 0 && ($i % 10) === 0 || $v === 0 && self::inRange($i % 10, 5, 9) || $v === 0 && self::inRange($i % 100, 11, 14)) { + if (self::isEqual($v, 0) && self::isEqual($i % 10, 0) || self::isEqual($v, 0) && self::inRange($i % 10, 5, 9) || self::isEqual($v, 0) && self::inRange($i % 100, 11, 14)) { return 'many'; } @@ -3988,7 +4092,7 @@ private static function getPluralCategoryUr(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -4006,7 +4110,7 @@ private static function getPluralCategoryUz(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -4024,7 +4128,7 @@ private static function getPluralCategoryVe(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -4042,11 +4146,11 @@ private static function getPluralCategoryVec(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } - if ($e === 0 && $i !== 0 && ($i % 1000000) === 0 && $v === 0 || ! self::inRange($e, 0, 5)) { + if (self::isEqual($e, 0) && ! self::isEqual($i, 0) && self::isEqual($i % 1000000, 0) && self::isEqual($v, 0) || ! self::inRange($e, 0, 5)) { return 'many'; } @@ -4078,7 +4182,7 @@ private static function getPluralCategoryVo(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -4096,7 +4200,7 @@ private static function getPluralCategoryVun(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -4132,7 +4236,7 @@ private static function getPluralCategoryWae(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -4164,7 +4268,7 @@ private static function getPluralCategoryXh(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -4182,7 +4286,7 @@ private static function getPluralCategoryXog(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($n === 1) { + if (self::isEqual($n, 1)) { return 'one'; } @@ -4200,7 +4304,7 @@ private static function getPluralCategoryYi(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 1 && $v === 0) { + if (self::isEqual($i, 1) && self::isEqual($v, 0)) { return 'one'; } @@ -4260,7 +4364,7 @@ private static function getPluralCategoryZu(float|int $n): string $t = self::getCompactExponent($n); $e = self::getExponent($n); - if ($i === 0 || $n === 1) { + if (self::isEqual($i, 0) || self::isEqual($n, 1)) { return 'one'; } @@ -4304,6 +4408,7 @@ public static function getPluralCategory(Locale $locale, float|int $number): str 'ckb' => self::getPluralCategoryCkb($number), 'cs' => self::getPluralCategoryCs($number), 'csw' => self::getPluralCategoryCsw($number), + 'cv' => self::getPluralCategoryCv($number), 'cy' => self::getPluralCategoryCy($number), 'da' => self::getPluralCategoryDa($number), 'de' => self::getPluralCategoryDe($number), @@ -4344,6 +4449,7 @@ public static function getPluralCategory(Locale $locale, float|int $number): str 'hy' => self::getPluralCategoryHy($number), 'ia' => self::getPluralCategoryIa($number), 'id' => self::getPluralCategoryId($number), + 'ie' => self::getPluralCategoryIe($number), 'ig' => self::getPluralCategoryIg($number), 'ii' => self::getPluralCategoryIi($number), 'io' => self::getPluralCategoryIo($number), @@ -4368,6 +4474,8 @@ public static function getPluralCategory(Locale $locale, float|int $number): str 'km' => self::getPluralCategoryKm($number), 'kn' => self::getPluralCategoryKn($number), 'ko' => self::getPluralCategoryKo($number), + 'kok' => self::getPluralCategoryKok($number), + 'kok-Latn' => self::getPluralCategoryKok_Latn($number), 'ks' => self::getPluralCategoryKs($number), 'ksb' => self::getPluralCategoryKsb($number), 'ksh' => self::getPluralCategoryKsh($number), @@ -4437,6 +4545,7 @@ public static function getPluralCategory(Locale $locale, float|int $number): str 'seh' => self::getPluralCategorySeh($number), 'ses' => self::getPluralCategorySes($number), 'sg' => self::getPluralCategorySg($number), + 'sgs' => self::getPluralCategorySgs($number), 'sh' => self::getPluralCategorySh($number), 'shi' => self::getPluralCategoryShi($number), 'si' => self::getPluralCategorySi($number), @@ -4533,6 +4642,7 @@ public static function getSupportedLocales(): array 'ckb', 'cs', 'csw', + 'cv', 'cy', 'da', 'de', @@ -4573,6 +4683,7 @@ public static function getSupportedLocales(): array 'hy', 'ia', 'id', + 'ie', 'ig', 'ii', 'io', @@ -4597,6 +4708,8 @@ public static function getSupportedLocales(): array 'km', 'kn', 'ko', + 'kok', + 'kok-Latn', 'ks', 'ksb', 'ksh', @@ -4666,6 +4779,7 @@ public static function getSupportedLocales(): array 'seh', 'ses', 'sg', + 'sgs', 'sh', 'shi', 'si', From d5d55e5b040935dc71865c103db8f0c4a2fd04cb Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:36:47 +0100 Subject: [PATCH 112/134] chore(phpstan): add intl package to scan paths --- phpstan.neon.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 86a7b0ff0c..ede37819bb 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -31,7 +31,7 @@ parameters: - packages/http - packages/http-client - packages/icon - #- packages/intl - 53 errors + - packages/intl - packages/kv-store - packages/log - packages/mail From f14be4615d71deb455d95152ef1395fdbf0ecde9 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 09:41:04 +0100 Subject: [PATCH 113/134] style(support): reformat --- packages/support/src/JavaScript/PackageManager.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/support/src/JavaScript/PackageManager.php b/packages/support/src/JavaScript/PackageManager.php index df5ddd55e6..f354f743f6 100644 --- a/packages/support/src/JavaScript/PackageManager.php +++ b/packages/support/src/JavaScript/PackageManager.php @@ -62,7 +62,9 @@ public static function detect(string $cwd): ?self { return array_find( array: PackageManager::cases(), - callback: fn (PackageManager $packageManager): bool => array_any($packageManager->getLockFiles(), fn (string $lockFile): bool => Filesystem\is_file($cwd . '/' . $lockFile)), + callback: fn (PackageManager $packageManager): bool => array_any($packageManager->getLockFiles(), fn (string $lockFile): bool => Filesystem\is_file($cwd + . '/' + . $lockFile)), ); } } From 72be108af71ab8ae52d6ff85c46669e1a95f06fe Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 10:33:44 +0100 Subject: [PATCH 114/134] feat(phpstan): make phpstan understand Lazy and Inject --- phpstan-baseline.neon | 14 ---- phpstan.neon.dist | 4 ++ .../LazyReadWritePropertiesExtensionTest.php | 64 +++++++++++++++++++ .../LazyReadWritePropertiesExtension.php | 36 +++++++++++ 4 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 tests/Integration/Framework/PHPStan/LazyReadWritePropertiesExtensionTest.php create mode 100644 tests/PHPStan/LazyReadWritePropertiesExtension.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d471a9dbe0..608c293880 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -35,20 +35,6 @@ parameters: path: packages/debug/src/functions.php count: 2 # Intentional: dd() delegates to ld() and dump() delegates to lw() - - - identifier: property.uninitializedReadonly - paths: - - packages/**/*Command.php - - packages/**/*Installer.php - - packages/console/src/Stubs/*Stub.php - - src/Tempest/Framework/Commands/*Command.php - - src/Tempest/Framework/Installers/* - - tests/Fixtures/**/*Command.php - - tests/Fixtures/Console/CommandWithArgumentName.php - - tests/Fixtures/Core/PublishesFilesConcreteClass.php - - tests/Fixtures/TestInstaller.php - - tests/Integration/Console/Fixtures/*Command.php - # Intentional: #[Inject] attribute on HasConsole trait initializes $console via container reflection after construction - identifier: disallowed.function path: packages/core/src/Composer.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ede37819bb..50736db582 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,6 +7,10 @@ services: class: Tests\Tempest\Architecture\ArchitectureTestCase tags: - phpat.test + - + class: Tests\Tempest\PHPStan\LazyReadWritePropertiesExtension + tags: + - phpstan.properties.readWriteExtension parameters: level: 5 tmpDir: .cache/phpstan diff --git a/tests/Integration/Framework/PHPStan/LazyReadWritePropertiesExtensionTest.php b/tests/Integration/Framework/PHPStan/LazyReadWritePropertiesExtensionTest.php new file mode 100644 index 0000000000..3b3d107ab7 --- /dev/null +++ b/tests/Integration/Framework/PHPStan/LazyReadWritePropertiesExtensionTest.php @@ -0,0 +1,64 @@ +createPropertyWithAttributes($this->createAttribute(Lazy::class)); + + $this->assertTrue($extension->isInitialized($property, 'property')); + } + + #[Test] + public function marks_properties_with_inject_attribute_as_initialized(): void + { + $extension = new LazyReadWritePropertiesExtension(); + $property = $this->createPropertyWithAttributes($this->createAttribute(Inject::class)); + + $this->assertTrue($extension->isInitialized($property, 'property')); + } + + #[Test] + public function does_not_mark_other_properties_as_initialized(): void + { + $extension = new LazyReadWritePropertiesExtension(); + $property = $this->createPropertyWithAttributes($this->createAttribute('Some\\Other\\Attribute')); + + $this->assertFalse($extension->isInitialized($property, 'property')); + } + + private function createPropertyWithAttributes(object ...$attributes): object + { + $property = $this->createStub(ExtendedPropertyReflection::class); + $property->method('getAttributes')->willReturn($attributes); + + return $property; + } + + private function createAttribute(string $name): object + { + return new readonly class($name) { + public function __construct( + private string $name, + ) {} + + public function getName(): string + { + return $this->name; + } + }; + } +} diff --git a/tests/PHPStan/LazyReadWritePropertiesExtension.php b/tests/PHPStan/LazyReadWritePropertiesExtension.php new file mode 100644 index 0000000000..d96609a51d --- /dev/null +++ b/tests/PHPStan/LazyReadWritePropertiesExtension.php @@ -0,0 +1,36 @@ +getAttributes() as $attribute) { + $attributeName = ltrim($attribute->getName(), '\\'); + + if ($attributeName === Lazy::class || $attributeName === Inject::class) { + return true; + } + } + + return false; + } +} From adf2ff97420c49fd4d75d4a7a10581087fd26548 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Feb 2026 16:24:27 +0100 Subject: [PATCH 115/134] test(datetime): remove flaky tests --- packages/datetime/tests/DateTimeTest.php | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/packages/datetime/tests/DateTimeTest.php b/packages/datetime/tests/DateTimeTest.php index 51d77bb0d5..ba74285734 100644 --- a/packages/datetime/tests/DateTimeTest.php +++ b/packages/datetime/tests/DateTimeTest.php @@ -1041,28 +1041,4 @@ public function from_parts_with_year_exceeding_intl_calendar_range_throws_overfl DateTime::fromParts(Timezone::UTC, PHP_INT_MAX, 1, 1); } - - #[Test] - public function from_parts_with_year_where_calendar_returns_false_throws_overflow(): void - { - $this->expectException(OverflowException::class); - - DateTime::fromParts(Timezone::UTC, 5_368_710, 1, 1); - } - - #[Test] - public function plus_month_on_extreme_year_throws_overflow(): void - { - $this->expectException(OverflowException::class); - - DateTime::fromParts(Timezone::UTC, 5_368_709, 12, 28)->plusMonth(); - } - - #[Test] - public function plus_year_on_extreme_year_throws_overflow(): void - { - $this->expectException(OverflowException::class); - - DateTime::fromParts(Timezone::UTC, 5_368_709, 6, 15)->plusYear(); - } } From 492c1de3550a31652844baaf7a501aaef98d6f87 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 14:59:47 +0100 Subject: [PATCH 116/134] test(view): add a regression test for dynamic view component scoping --- tests/Integration/View/ViewComponentTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index 4da32987b1..6ef4dbeb38 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -6,6 +6,7 @@ use Generator; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use Tempest\Core\Environment; use Tempest\Http\Session\FormSession; use Tempest\Validation\FailingRule; @@ -872,6 +873,22 @@ public function test_dynamic_view_component_with_slot(): void $this->assertSnippetsMatch('
test
', $html); } + #[Test] + public function dynamic_view_component_keeps_foreach_scope(): void + { + $this->view->registerViewComponent('x-test', '
'); + + $html = $this->view->render( + <<<'HTML' + {{ $item }} + HTML, + name: 'x-test', + items: ['a', 'b'], + ); + + $this->assertSnippetsMatch('
a
b
', $html); + } + public function test_nested_slots(): void { $this->view->registerViewComponent('x-a', ''); From 96a3651a4152b3e553027fddb52641e7957da0ca Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:26:16 +0100 Subject: [PATCH 117/134] fix(database): narrow serializer return types to string --- .../database/src/Serializers/DataTransferObjectSerializer.php | 2 +- packages/database/src/Serializers/HashedSerializer.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/database/src/Serializers/DataTransferObjectSerializer.php b/packages/database/src/Serializers/DataTransferObjectSerializer.php index 60162dfcfe..a9aacf5f06 100644 --- a/packages/database/src/Serializers/DataTransferObjectSerializer.php +++ b/packages/database/src/Serializers/DataTransferObjectSerializer.php @@ -46,7 +46,7 @@ public static function accepts(PropertyReflector|TypeReflector $type): bool return $type->isClass() && $type->asClass()->getAttribute(SerializeAs::class) !== null; } - public function serialize(mixed $input): array|string + public function serialize(mixed $input): string { if (is_array($input)) { return Json\encode($this->serializeWithType($input)); diff --git a/packages/database/src/Serializers/HashedSerializer.php b/packages/database/src/Serializers/HashedSerializer.php index b6b2b2b776..b02ed9f07a 100644 --- a/packages/database/src/Serializers/HashedSerializer.php +++ b/packages/database/src/Serializers/HashedSerializer.php @@ -3,6 +3,7 @@ namespace Tempest\Database\Serializers; use Tempest\Cryptography\Password\PasswordHasher; +use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized; use Tempest\Mapper\Serializer; final readonly class HashedSerializer implements Serializer @@ -14,7 +15,7 @@ public function __construct( public function serialize(mixed $input): string { if (! is_string($input)) { - return $input; + throw new ValueCouldNotBeSerialized('string'); } if (! $this->passwordHasher->analyze($input)) { From 6a75146c64dcca56a41c57ad527989b7195a5cd0 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:26:27 +0100 Subject: [PATCH 118/134] fix(database): fix return types --- packages/database/src/DatabaseInsightsProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/database/src/DatabaseInsightsProvider.php b/packages/database/src/DatabaseInsightsProvider.php index ea485f4365..a3afaba7e8 100644 --- a/packages/database/src/DatabaseInsightsProvider.php +++ b/packages/database/src/DatabaseInsightsProvider.php @@ -39,7 +39,7 @@ private function getDatabaseEngine(): string SQLiteConfig::class => 'SQLite', PostgresConfig::class => 'PostgreSQL', MysqlConfig::class => 'MySQL', - default => ['Unknown', null], + default => 'Unknown', }; } @@ -68,7 +68,7 @@ private function getDatabaseVersion(): Insight } } - private function getSQLitePath(): null|Insight|string + private function getSQLitePath(): ?string { if (! $this->databaseConfig instanceof SQLiteConfig) { return null; From 5864ced8f09fb96fe137c99dbb7a28c872b9c6a3 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:26:31 +0100 Subject: [PATCH 119/134] fix(database): add config property to Connection interface --- packages/database/src/Connection/Connection.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/database/src/Connection/Connection.php b/packages/database/src/Connection/Connection.php index afcb58aab6..4b1b5011de 100644 --- a/packages/database/src/Connection/Connection.php +++ b/packages/database/src/Connection/Connection.php @@ -5,9 +5,14 @@ namespace Tempest\Database\Connection; use PDOStatement; +use Tempest\Database\Config\DatabaseConfig; interface Connection { + public DatabaseConfig $config { + get; + } + public function beginTransaction(): bool; public function commit(): bool; From 18bbd3dbf838e62010d3130ed3ffedaae4db306e Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:26:36 +0100 Subject: [PATCH 120/134] fix(database): remove dead code in migration handling --- packages/database/src/Migrations/MigrationManager.php | 4 ---- packages/database/src/Migrations/RunnableMigrations.php | 7 +++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index 530ac36d6c..bfcabca2a6 100644 --- a/packages/database/src/Migrations/MigrationManager.php +++ b/packages/database/src/Migrations/MigrationManager.php @@ -194,10 +194,6 @@ public function executeUp(MigratesUp $migration): void $statement = $migration->up(); - if ($statement === null) { - return; - } - if ($statement instanceof CompoundStatement) { $statements = $statement->statements; } else { diff --git a/packages/database/src/Migrations/RunnableMigrations.php b/packages/database/src/Migrations/RunnableMigrations.php index 8c532dbead..c90a8de193 100644 --- a/packages/database/src/Migrations/RunnableMigrations.php +++ b/packages/database/src/Migrations/RunnableMigrations.php @@ -10,11 +10,11 @@ use Tempest\Database\MigratesUp; use Traversable; -/** @implements IteratorAggregate */ +/** @implements IteratorAggregate */ final class RunnableMigrations implements IteratorAggregate { /** - * @param MigratesUp[] $migrations + * @param array $migrations */ public function __construct( private array $migrations = [], @@ -22,6 +22,9 @@ public function __construct( usort($this->migrations, static fn (MigratesUp|MigratesDown $a, MigratesUp|MigratesDown $b) => strnatcmp($a->name, $b->name)); } + /** + * @return Traversable + */ public function getIterator(): Traversable { return new ArrayIterator($this->migrations); From 706a3d7202c13cb2280e30b700fb38519f16fdfc Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:26:40 +0100 Subject: [PATCH 121/134] fix(database): fix property hook and redundant cast --- packages/database/src/RawSql.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/database/src/RawSql.php b/packages/database/src/RawSql.php index 2f8023061d..de322f2f3d 100644 --- a/packages/database/src/RawSql.php +++ b/packages/database/src/RawSql.php @@ -9,7 +9,7 @@ final class RawSql { - private ?RawSqlDatabaseContext $context { + private RawSqlDatabaseContext $context { get => $this->context ??= new RawSqlDatabaseContext($this->dialect); } @@ -28,7 +28,7 @@ public function compile(): string return $this->replaceNamedBindings($this->sql, $resolvedBindings); } - return $this->replacePositionalBindings($this->sql, array_values($resolvedBindings)); + return $this->replacePositionalBindings($this->sql, $resolvedBindings); } public function toImmutableString(): ImmutableString From bb1c2f6ed35ad96c8669ab3b43a48508fd9e7e1b Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:26:44 +0100 Subject: [PATCH 122/134] fix(database): fix type errors in SelectStatement::compile --- .../src/QueryStatements/SelectStatement.php | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 6f7371e4b9..b722f60d88 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -45,29 +45,30 @@ public function withFields(ImmutableArray $fields): self public function compile(DatabaseDialect $dialect): string { - $columns = $this->fields->isEmpty() - ? '*' - : $this->fields - ->flatMap(function (string|Stringable|FieldStatement $field) { - if ($field instanceof FieldStatement) { - return $field; - } - - return str($field)->explode(',')->toArray(); - }) - ->map(function (string|Stringable|FieldStatement $field) use ($dialect) { - if (! $field instanceof FieldStatement) { - $field = new FieldStatement($field); - } - - return $field->compile($dialect); - }) - ->implode(', '); + $columns = '*'; + + if ($this->fields->isNotEmpty()) { + $compiledColumns = []; + + foreach ($this->fields as $field) { + if ($field instanceof FieldStatement) { + $compiledColumns[] = $field->compile($dialect); + + continue; + } + + foreach (str($field)->explode(',') as $splitField) { + $compiledColumns[] = new FieldStatement($splitField)->compile($dialect); + } + } - $query = new ImmutableArray([ + $columns = implode(', ', $compiledColumns); + } + + $query = [ 'SELECT ' . $columns, 'FROM ' . $this->table, - ]); + ]; if ($this->join->isNotEmpty()) { $query[] = $this->join @@ -111,12 +112,11 @@ public function compile(DatabaseDialect $dialect): string if ($this->raw->isNotEmpty()) { $query[] = $this->raw ->map(fn (RawStatement $raw) => $raw->compile($dialect)) - ->implode(' '); + ->implode(' ') + ->toString(); } - $compiled = $query->implode(' '); - - return $compiled; + return implode(' ', $query); } public function __clone(): void From b739f4af243f59e8bc7ea5dfc8bf6ba03c013a5e Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:27:15 +0100 Subject: [PATCH 123/134] fix(database): fix where-clause mutations and generic types --- .../QueryBuilders/CountQueryBuilder.php | 5 +++-- .../QueryBuilders/DeleteQueryBuilder.php | 5 +++-- .../HasConvenientWhereMethods.php | 1 - .../HasWhereQueryBuilderMethods.php | 22 +++++++++---------- .../QueryBuilders/InsertQueryBuilder.php | 2 +- .../QueryBuilders/SelectQueryBuilder.php | 17 ++++++++++---- .../QueryBuilders/SupportsWhereStatements.php | 4 +++- .../QueryBuilders/UpdateQueryBuilder.php | 15 ++++++------- .../QueryStatements/HasWhereStatements.php | 2 +- 9 files changed, 42 insertions(+), 31 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index e9d5a6fb99..4c15538b9f 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -63,11 +63,11 @@ public function __construct(string|object $model, ?string $column = null) */ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $source, ?string $column = null): CountQueryBuilder { - $builder = new self($source->model->model, $column); + $builder = new self($source->model->getName(), $column); $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres[] = $where; + $builder->wheres->offsetSet(null, $where); } if ($source instanceof SupportsJoins) { @@ -80,6 +80,7 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou } } + /** @var CountQueryBuilder $builder */ return $builder; } diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 9da1c62355..24ea098fb9 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -50,13 +50,14 @@ public function __construct(string|object $model) */ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $source): DeleteQueryBuilder { - $builder = new self($source->model->model); + $builder = new self($source->model->getName()); $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres[] = $where; + $builder->wheres->offsetSet(null, $where); } + /** @var DeleteQueryBuilder $builder */ return $builder; } diff --git a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php index ca98ef9a4c..f3a15d1af6 100644 --- a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php @@ -13,7 +13,6 @@ /** * @template TModel of object - * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements * * Shared methods for building WHERE conditions and convenience WHERE methods. */ diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 0353395b7c..37564d7070 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -10,8 +10,8 @@ /** * @template TModel of object - * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements + * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements * @use \Tempest\Database\Builder\QueryBuilders\HasConvenientWhereMethods */ trait HasWhereQueryBuilderMethods @@ -52,7 +52,7 @@ public function whereField(string $field, mixed $value, string|WhereOperator $op return $this->andWhere($field, $value, $operator); } - $this->wheres[] = new WhereStatement($condition['sql']); + $this->wheres->offsetSet(null, new WhereStatement($condition['sql'])); $this->bind(...$condition['bindings']); return $this; @@ -69,7 +69,7 @@ public function andWhere(string $field, mixed $value, WhereOperator $operator = $fieldDefinition = $this->model->getFieldDefinition($field); $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); - $this->wheres[] = new WhereStatement("AND {$condition['sql']}"); + $this->wheres->offsetSet(null, new WhereStatement("AND {$condition['sql']}")); $this->bind(...$condition['bindings']); return $this; @@ -86,7 +86,7 @@ public function orWhere(string $field, mixed $value, WhereOperator $operator = W $fieldDefinition = $this->model->getFieldDefinition($field); $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); - $this->wheres[] = new WhereStatement("OR {$condition['sql']}"); + $this->wheres->offsetSet(null, new WhereStatement("OR {$condition['sql']}")); $this->bind(...$condition['bindings']); return $this; @@ -103,7 +103,7 @@ public function whereRaw(string $statement, mixed ...$bindings): self return $this->andWhereRaw($statement, ...$bindings); } - $this->wheres[] = new WhereStatement($statement); + $this->wheres->offsetSet(null, new WhereStatement($statement)); $this->bind(...$bindings); return $this; @@ -116,7 +116,7 @@ public function whereRaw(string $statement, mixed ...$bindings): self */ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self { - $this->wheres[] = new WhereStatement("AND {$rawCondition}"); + $this->wheres->offsetSet(null, new WhereStatement("AND {$rawCondition}")); $this->bind(...$bindings); return $this; @@ -129,7 +129,7 @@ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self */ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self { - $this->wheres[] = new WhereStatement("OR {$rawCondition}"); + $this->wheres->offsetSet(null, new WhereStatement("OR {$rawCondition}")); $this->bind(...$bindings); return $this; @@ -148,7 +148,7 @@ public function whereGroup(Closure $callback): self $group = $groupBuilder->build(); if (! $group->conditions->isEmpty()) { - $this->wheres[] = $group; + $this->wheres->offsetSet(null, $group); $this->bind(...$groupBuilder->getBindings()); } @@ -164,7 +164,7 @@ public function whereGroup(Closure $callback): self public function andWhereGroup(Closure $callback): self { if ($this->wheres->isNotEmpty()) { - $this->wheres[] = new WhereStatement('AND'); + $this->wheres->offsetSet(null, new WhereStatement('AND')); } return $this->whereGroup($callback); @@ -179,7 +179,7 @@ public function andWhereGroup(Closure $callback): self public function orWhereGroup(Closure $callback): self { if ($this->wheres->isNotEmpty()) { - $this->wheres[] = new WhereStatement('OR'); + $this->wheres->offsetSet(null, new WhereStatement('OR')); } return $this->whereGroup($callback); diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index a438e90351..2d18de7e0b 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -509,7 +509,7 @@ private function serializeIterableValue(string $key, mixed $value): mixed return $value; } - if (! $this->model?->reflector->hasProperty($key)) { + if (! $this->model->reflector->hasProperty($key)) { return $value; } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 202d91afa6..2b7ee7f5d7 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -65,7 +65,6 @@ final class SelectQueryBuilder implements BuildsQuery, SupportsWhereStatements, public ImmutableArray $wheres { get => $this->select->where; - set => $this->select->where; } /** @@ -93,7 +92,7 @@ public function first(mixed ...$bindings): mixed $query = $this->build(...$bindings); if (! $this->model->isObjectModel()) { - return $query->fetchFirst(); + return $this->coerceFirstResult($query->fetchFirst()); } $result = map($query->fetch()) @@ -152,11 +151,11 @@ public function get(PrimaryKey $id): mixed */ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $source, mixed ...$fields): SelectQueryBuilder { - $builder = new self($source->model->model, ...$fields); + $builder = new self($source->model->getName(), ...$fields); $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres[] = $where; + $builder->wheres->offsetSet(null, $where); } if ($source instanceof SupportsJoins) { @@ -169,6 +168,7 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou } } + /** @var SelectQueryBuilder $builder */ return $builder; } @@ -398,6 +398,15 @@ private function clone(): self return clone $this; } + /** + * @param mixed $result + * @return TModel|null + */ + private function coerceFirstResult(mixed $result): mixed + { + return $result; + } + /** * Gets all resolved relations with their join statements. * diff --git a/packages/database/src/Builder/QueryBuilders/SupportsWhereStatements.php b/packages/database/src/Builder/QueryBuilders/SupportsWhereStatements.php index cb2f22af5d..3aebecb688 100644 --- a/packages/database/src/Builder/QueryBuilders/SupportsWhereStatements.php +++ b/packages/database/src/Builder/QueryBuilders/SupportsWhereStatements.php @@ -3,6 +3,8 @@ namespace Tempest\Database\Builder\QueryBuilders; use Tempest\Database\Builder\WhereOperator; +use Tempest\Database\QueryStatements\WhereGroupStatement; +use Tempest\Database\QueryStatements\WhereStatement; use Tempest\Support\Arr\ImmutableArray; /** @@ -13,7 +15,7 @@ interface SupportsWhereStatements /** * The current WHERE statements for this query builder. * - * @var ImmutableArray + * @var ImmutableArray */ public ImmutableArray $wheres { get; diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index b0ebd8fd46..da66986fb5 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -83,13 +83,14 @@ public function __construct( */ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $source, mixed ...$values): UpdateQueryBuilder { - $builder = new self($source->model->model, $values, get(SerializerFactory::class)); + $builder = new self($source->model->getName(), $values, get(SerializerFactory::class)); $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres[] = $where; + $builder->wheres->offsetSet(null, $where); } + /** @var UpdateQueryBuilder $builder */ return $builder; } @@ -550,10 +551,12 @@ public function whereField(string $field, mixed $value, string|WhereOperator $op $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); if ($this->wheres->isNotEmpty()) { - return $this->andWhere($field, $value, $operator); + $this->andWhere($field, $value, $operator); + + return $this; } - $this->wheres[] = new WhereStatement($condition['sql']); + $this->wheres->offsetSet(null, new WhereStatement($condition['sql'])); $this->bind(...$condition['bindings']); return $this; @@ -587,10 +590,6 @@ private function hasRelationUpdates(): bool private function isRelationField(string $field): bool { - if (! $this->model) { - return false; - } - return $this->model->getHasMany($field) || $this->model->getHasOne($field); } diff --git a/packages/database/src/QueryStatements/HasWhereStatements.php b/packages/database/src/QueryStatements/HasWhereStatements.php index ba77fa4dcf..91869b1046 100644 --- a/packages/database/src/QueryStatements/HasWhereStatements.php +++ b/packages/database/src/QueryStatements/HasWhereStatements.php @@ -6,7 +6,7 @@ interface HasWhereStatements { - /** @var ImmutableArray */ + /** @var ImmutableArray */ public ImmutableArray $where { get; } From a0745d4e0d8a2d59eae0fc699969371776a76759 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:27:26 +0100 Subject: [PATCH 124/134] fix(database): preserve generic types --- packages/database/src/IsDatabaseModel.php | 31 +++++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index e0f82e984f..754e4032a2 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -6,6 +6,7 @@ use Tempest\Database\Builder\QueryBuilders\CountQueryBuilder; use Tempest\Database\Builder\QueryBuilders\InsertQueryBuilder; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; @@ -21,6 +22,14 @@ trait IsDatabaseModel #[IsBindingValue, SkipValidation] public PrimaryKey $id; + /** + * @return QueryBuilder + */ + private static function queryBuilder(): QueryBuilder + { + return query(static::class); + } + /** * Returns a builder for selecting records using this model's table. * @@ -28,7 +37,7 @@ trait IsDatabaseModel */ public static function select(): SelectQueryBuilder { - return query(self::class)->select(); + return self::queryBuilder()->select(); } /** @@ -38,7 +47,7 @@ public static function select(): SelectQueryBuilder */ public static function insert(): InsertQueryBuilder { - return query(self::class)->insert(); + return self::queryBuilder()->insert(); } /** @@ -48,7 +57,7 @@ public static function insert(): InsertQueryBuilder */ public static function count(): CountQueryBuilder { - return query(self::class)->count(); + return self::queryBuilder()->count(); } /** @@ -56,7 +65,7 @@ public static function count(): CountQueryBuilder */ public static function new(mixed ...$params): self { - return query(self::class)->new(...$params); + return self::queryBuilder()->new(...$params); } /** @@ -72,7 +81,7 @@ public static function findById(string|int|PrimaryKey $id): self */ public static function resolve(string $input): self { - return query(self::class)->resolve($input); + return self::queryBuilder()->resolve($input); } /** @@ -80,7 +89,7 @@ public static function resolve(string $input): self */ public static function get(string|int|PrimaryKey $id, array $relations = []): ?self { - return query(self::class)->get($id, $relations); + return self::queryBuilder()->get($id, $relations); } /** @@ -90,7 +99,7 @@ public static function get(string|int|PrimaryKey $id, array $relations = []): ?s */ public static function all(array $relations = []): array { - return query(self::class)->all($relations); + return self::queryBuilder()->all($relations); } /** @@ -105,7 +114,7 @@ public static function all(array $relations = []): array */ public static function find(mixed ...$conditions): SelectQueryBuilder { - return query(self::class)->find(...$conditions); + return self::queryBuilder()->find(...$conditions); } /** @@ -120,7 +129,7 @@ public static function find(mixed ...$conditions): SelectQueryBuilder */ public static function create(mixed ...$params): self { - return query(self::class)->create(...$params); + return self::queryBuilder()->create(...$params); } /** @@ -140,7 +149,7 @@ public static function create(mixed ...$params): self */ public static function findOrNew(array $find, array $update): self { - return query(self::class)->findOrNew($find, $update); + return self::queryBuilder()->findOrNew($find, $update); } /** @@ -159,7 +168,7 @@ public static function findOrNew(array $find, array $update): self */ public static function updateOrCreate(array $find, array $update): self { - return query(self::class)->updateOrCreate($find, $update); + return self::queryBuilder()->updateOrCreate($find, $update); } /** From 96cd9e1f5afe2c87b5c607de84aa98e73eff7f1a Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:27:31 +0100 Subject: [PATCH 125/134] fix(database): narrow ask() result --- packages/database/src/Commands/MakeMigrationCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/database/src/Commands/MakeMigrationCommand.php b/packages/database/src/Commands/MakeMigrationCommand.php index 33e33814cb..7e6c09507f 100644 --- a/packages/database/src/Commands/MakeMigrationCommand.php +++ b/packages/database/src/Commands/MakeMigrationCommand.php @@ -89,6 +89,7 @@ public function __invoke( $table = $this->resolveTableName($table); [$migrationName, $className] = $this->resolveNames($name, $alter); + /** @var MigrationType $migrationType */ $stub = $this->resolveStub($migrationType, $alter); $targetPath = match ($migrationType) { From ad8b4ad1413b34153fa0d3e78deff43329c1bcae Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:27:35 +0100 Subject: [PATCH 126/134] fix(database): fix callable PHPDoc --- .../src/Builder/QueryBuilders/TransformsQueryBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php index d8e89b05ea..3b503fea8b 100644 --- a/packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php @@ -11,7 +11,7 @@ trait TransformsQueryBuilder { /** * Returns a new instance of the query builder with the given callback applied. - * @param callable(static) $callback + * @param callable(static): mixed $callback */ public function transform(callable $callback): static { From adb53161f373dad784282675393e2e79e9bcd3a5 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:27:40 +0100 Subject: [PATCH 127/134] chore(phpstan): enable database package in analysis --- packages/database/src/functions.php | 4 ++-- phpstan.neon.dist | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/database/src/functions.php b/packages/database/src/functions.php index c91b2ff343..cdf059e174 100644 --- a/packages/database/src/functions.php +++ b/packages/database/src/functions.php @@ -10,7 +10,7 @@ * * @template TModel of object * @param class-string|string|TModel $model - * @return QueryBuilder + * @return ($model is class-string|TModel ? QueryBuilder : QueryBuilder) */ function query(string|object $model): QueryBuilder { @@ -22,7 +22,7 @@ function query(string|object $model): QueryBuilder * * @template TModel of object * @param class-string|string|TModel $model - * @return ModelInspector + * @return ModelInspector * @internal */ function inspect(string|object $model): ModelInspector diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 50736db582..8810884dae 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -26,7 +26,7 @@ parameters: - packages/container - packages/core - packages/cryptography - #- packages/database - 342 errors + - packages/database - packages/datetime - packages/debug - packages/discovery From 3dd61e9dc056c8706205fd0d001ad64d1af5a522 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:28:40 +0100 Subject: [PATCH 128/134] fix: lock phpstan until fixed --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 2f53c1ad18..7c17954b4a 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "psr/http-factory": "^1.0", "psr/http-message": "^1.0|^2.0", "psr/log": "^3.0.0", - "rector/rector": "^2.3.2", + "rector/rector": "2.3.1", "symfony/cache": "^7.3", "symfony/filesystem": "^7.3", "symfony/mailer": "^7.2.6", @@ -74,7 +74,7 @@ "patrickbussmann/oauth2-apple": "^0.3", "phpat/phpat": "^0.11.0", "phpbench/phpbench": "84.x-dev", - "phpstan/phpstan": "2.1.38", + "phpstan/phpstan": "2.1.33", "phpunit/phpunit": "^12.5.8", "predis/predis": "^3.0.0", "riskio/oauth2-auth0": "^2.4", From c5be00448de2ba057db7ae70de9b0371edd8fc01 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:49:45 +0100 Subject: [PATCH 129/134] refactor(database): add appendWhere helper --- .../QueryBuilders/CountQueryBuilder.php | 2 +- .../QueryBuilders/DeleteQueryBuilder.php | 2 +- .../HasWhereQueryBuilderMethods.php | 19 ++++++++++--------- .../QueryBuilders/SelectQueryBuilder.php | 2 +- .../QueryBuilders/UpdateQueryBuilder.php | 4 ++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 4c15538b9f..d761821634 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -67,7 +67,7 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres->offsetSet(null, $where); + $builder->appendWhere($where); } if ($source instanceof SupportsJoins) { diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 24ea098fb9..06e98816b2 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -54,7 +54,7 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres->offsetSet(null, $where); + $builder->appendWhere($where); } /** @var DeleteQueryBuilder $builder */ diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 37564d7070..18fae5bc8f 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -52,7 +52,7 @@ public function whereField(string $field, mixed $value, string|WhereOperator $op return $this->andWhere($field, $value, $operator); } - $this->wheres->offsetSet(null, new WhereStatement($condition['sql'])); + $this->appendWhere(new WhereStatement($condition['sql'])); $this->bind(...$condition['bindings']); return $this; @@ -69,7 +69,7 @@ public function andWhere(string $field, mixed $value, WhereOperator $operator = $fieldDefinition = $this->model->getFieldDefinition($field); $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); - $this->wheres->offsetSet(null, new WhereStatement("AND {$condition['sql']}")); + $this->appendWhere(new WhereStatement("AND {$condition['sql']}")); $this->bind(...$condition['bindings']); return $this; @@ -86,7 +86,7 @@ public function orWhere(string $field, mixed $value, WhereOperator $operator = W $fieldDefinition = $this->model->getFieldDefinition($field); $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); - $this->wheres->offsetSet(null, new WhereStatement("OR {$condition['sql']}")); + $this->appendWhere(new WhereStatement("OR {$condition['sql']}")); $this->bind(...$condition['bindings']); return $this; @@ -103,7 +103,7 @@ public function whereRaw(string $statement, mixed ...$bindings): self return $this->andWhereRaw($statement, ...$bindings); } - $this->wheres->offsetSet(null, new WhereStatement($statement)); + $this->appendWhere(new WhereStatement($statement)); $this->bind(...$bindings); return $this; @@ -116,7 +116,7 @@ public function whereRaw(string $statement, mixed ...$bindings): self */ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self { - $this->wheres->offsetSet(null, new WhereStatement("AND {$rawCondition}")); + $this->appendWhere(new WhereStatement("AND {$rawCondition}")); $this->bind(...$bindings); return $this; @@ -129,7 +129,7 @@ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self */ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self { - $this->wheres->offsetSet(null, new WhereStatement("OR {$rawCondition}")); + $this->appendWhere(new WhereStatement("OR {$rawCondition}")); $this->bind(...$bindings); return $this; @@ -143,12 +143,13 @@ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self */ public function whereGroup(Closure $callback): self { + /** @var WhereGroupBuilder $groupBuilder */ $groupBuilder = new WhereGroupBuilder($this->model); $callback($groupBuilder); $group = $groupBuilder->build(); if (! $group->conditions->isEmpty()) { - $this->wheres->offsetSet(null, $group); + $this->appendWhere($group); $this->bind(...$groupBuilder->getBindings()); } @@ -164,7 +165,7 @@ public function whereGroup(Closure $callback): self public function andWhereGroup(Closure $callback): self { if ($this->wheres->isNotEmpty()) { - $this->wheres->offsetSet(null, new WhereStatement('AND')); + $this->appendWhere(new WhereStatement('AND')); } return $this->whereGroup($callback); @@ -179,7 +180,7 @@ public function andWhereGroup(Closure $callback): self public function orWhereGroup(Closure $callback): self { if ($this->wheres->isNotEmpty()) { - $this->wheres->offsetSet(null, new WhereStatement('OR')); + $this->appendWhere(new WhereStatement('OR')); } return $this->whereGroup($callback); diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 2b7ee7f5d7..c947c7da38 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -155,7 +155,7 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres->offsetSet(null, $where); + $builder->appendWhere($where); } if ($source instanceof SupportsJoins) { diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index da66986fb5..e61363ffc1 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -87,7 +87,7 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou $builder->bind(...$source->bindings); foreach ($source->wheres as $where) { - $builder->wheres->offsetSet(null, $where); + $builder->appendWhere($where); } /** @var UpdateQueryBuilder $builder */ @@ -556,7 +556,7 @@ public function whereField(string $field, mixed $value, string|WhereOperator $op return $this; } - $this->wheres->offsetSet(null, new WhereStatement($condition['sql'])); + $this->appendWhere(new WhereStatement($condition['sql'])); $this->bind(...$condition['bindings']); return $this; From 6fdbebbc3793ea6148af18be932303ebad50834c Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:50:00 +0100 Subject: [PATCH 130/134] fix(database): improve generics --- .../src/Builder/QueryBuilders/CountQueryBuilder.php | 7 +++++-- .../src/Builder/QueryBuilders/DeleteQueryBuilder.php | 7 +++++-- .../Builder/QueryBuilders/HasWhereQueryBuilderMethods.php | 8 +++++++- .../src/Builder/QueryBuilders/SelectQueryBuilder.php | 7 +++++-- .../src/Builder/QueryBuilders/UpdateQueryBuilder.php | 7 +++++-- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index d761821634..b2b7305600 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -21,11 +21,14 @@ * @template TModel of object * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery * @implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements - * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class CountQueryBuilder implements BuildsQuery, SupportsWhereStatements { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; + use HasConditions; + use OnDatabase; + /** @use HasWhereQueryBuilderMethods */ + use HasWhereQueryBuilderMethods; + use TransformsQueryBuilder; private CountStatement $count; diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 06e98816b2..d7e40a8dd5 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -16,11 +16,14 @@ * @template TModel of object * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery * @implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements - * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class DeleteQueryBuilder implements BuildsQuery, SupportsWhereStatements { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; + use HasConditions; + use OnDatabase; + /** @use HasWhereQueryBuilderMethods */ + use HasWhereQueryBuilderMethods; + use TransformsQueryBuilder; private DeleteStatement $delete; diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 18fae5bc8f..82d6aa1dc3 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -4,6 +4,7 @@ use Closure; use Tempest\Database\Builder\WhereOperator; +use Tempest\Database\QueryStatements\WhereGroupStatement; use Tempest\Database\QueryStatements\WhereStatement; use function Tempest\Support\str; @@ -12,12 +13,17 @@ * @template TModel of object * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements - * @use \Tempest\Database\Builder\QueryBuilders\HasConvenientWhereMethods */ trait HasWhereQueryBuilderMethods { + /** @use HasConvenientWhereMethods */ use HasConvenientWhereMethods; + protected function appendWhere(WhereStatement|WhereGroupStatement $where): void + { + $this->wheres->offsetSet(null, $where); + } + /** * Adds a SQL `WHERE` condition to the query. If the `$statement` looks like raw SQL, the method will assume it is and call `whereRaw`. Otherwise, `whereField` will be called. * diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index c947c7da38..8c48debd12 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -39,11 +39,14 @@ * @implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements * @implements \Tempest\Database\Builder\QueryBuilders\SupportsJoins * @implements \Tempest\Database\Builder\QueryBuilders\SupportsRelations - * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class SelectQueryBuilder implements BuildsQuery, SupportsWhereStatements, SupportsJoins, SupportsRelations { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; + use HasConditions; + use OnDatabase; + /** @use HasWhereQueryBuilderMethods */ + use HasWhereQueryBuilderMethods; + use TransformsQueryBuilder; public ModelInspector $model; diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index e61363ffc1..65d9442205 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -31,11 +31,14 @@ * @template TModel of object * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery * @implements \Tempest\Database\Builder\QueryBuilders\SupportsWhereStatements - * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class UpdateQueryBuilder implements BuildsQuery, SupportsWhereStatements { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; + use HasConditions; + use OnDatabase; + /** @use HasWhereQueryBuilderMethods */ + use HasWhereQueryBuilderMethods; + use TransformsQueryBuilder; private UpdateStatement $update; From a6d8b1fc7dfb4756a6ac287ba2224e952779175f Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 16:50:25 +0100 Subject: [PATCH 131/134] test(database): add generics assertions --- .../QueryBuilderGenericsTypeAssertions.php | 99 +++++++++++++++++++ phpstan-baseline.neon | 5 + 2 files changed, 104 insertions(+) create mode 100644 packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php diff --git a/packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php b/packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php new file mode 100644 index 0000000000..8a6b3b6545 --- /dev/null +++ b/packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php @@ -0,0 +1,99 @@ +', $queryBuilder); + +$selectQueryBuilder = $queryBuilder->select(); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', $selectQueryBuilder); + +$whereField = $selectQueryBuilder->whereField('id', 1); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', $whereField); + +$where = $selectQueryBuilder->where('id', 1); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', $where); + +$whereIn = $selectQueryBuilder->whereIn('id', [1, 2]); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', $whereIn); + +$selectQueryBuilder->whereGroup(static function ($group): void { + \PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\WhereGroupBuilder', $group); + + $group->whereGroup(static function ($nestedGroup): void { + \PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\WhereGroupBuilder', $nestedGroup); + }); + + $group->andWhereGroup(static function ($nestedGroup): void { + \PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\WhereGroupBuilder', $nestedGroup); + }); + + $group->orWhereGroup(static function ($nestedGroup): void { + \PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\WhereGroupBuilder', $nestedGroup); + }); +}); + +\PHPStan\Testing\assertType('Tempest\\Database\\Tests\\QueryStatements\\StubModel|null', $where->first()); +\PHPStan\Testing\assertType('array', $where->all()); + +$countBuilder = $queryBuilder->count(); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\CountQueryBuilder', $countBuilder); + +$countWhere = $countBuilder->whereIn('id', [1, 2]); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\CountQueryBuilder', $countWhere); + +$countFromSelect = CountQueryBuilder::fromQueryBuilder($where); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\CountQueryBuilder', $countFromSelect); + +$updateBuilder = $queryBuilder->update(name: 'Rudy'); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\UpdateQueryBuilder', $updateBuilder); + +$updateWhere = $updateBuilder->whereIn('id', [1, 2]); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\UpdateQueryBuilder', $updateWhere); + +$updateFromSelect = UpdateQueryBuilder::fromQueryBuilder($where, name: 'Rudy'); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\UpdateQueryBuilder', $updateFromSelect); + +$deleteBuilder = $queryBuilder->delete(); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\DeleteQueryBuilder', $deleteBuilder); + +$deleteWhere = $deleteBuilder->whereIn('id', [1, 2]); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\DeleteQueryBuilder', $deleteWhere); + +$deleteFromSelect = DeleteQueryBuilder::fromQueryBuilder($where); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\DeleteQueryBuilder', $deleteFromSelect); + +$selectFromCount = SelectQueryBuilder::fromQueryBuilder($countBuilder); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', $selectFromCount); + +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', StubModel::select()); +\PHPStan\Testing\assertType('Tempest\\Database\\Tests\\QueryStatements\\StubModel|null', StubModel::select()->where('id', 1)->first()); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', ChildStubModel::select()); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\CountQueryBuilder', ChildStubModel::count()); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', ChildStubModel::find(id: 1)); +\PHPStan\Testing\assertType('Tempest\\Database\\Tests\\TypeInference\\ChildStubModel|null', ChildStubModel::findById(1)); +\PHPStan\Testing\assertType('Tempest\\Database\\Tests\\TypeInference\\ChildStubModel|null', ChildStubModel::get(1)); +\PHPStan\Testing\assertType('array', ChildStubModel::all()); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 608c293880..9858fc847b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -105,3 +105,8 @@ parameters: path: packages/process/src/Testing/ProcessTester.php count: 3 # Intentional: explicit assertTrue() calls keep callback-only branches counted as assertions in tests + - + identifier: method.alreadyNarrowedType + path: tests/Integration/Database/GroupedWhereMethodsTest.php + count: 1 + # Intentional: assertion keeps explicit null-check semantics in grouped-where integration test From 13426631f2759f1e6b8a58faec12917a9c5dfd1c Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 17:09:32 +0100 Subject: [PATCH 132/134] refactor(database): improve more generics --- .../QueryBuilders/WhereGroupBuilder.php | 7 +- packages/database/src/IsDatabaseModel.php | 66 +++++++++---------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php index 6fac0afeeb..caab175606 100644 --- a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php @@ -154,13 +154,14 @@ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self /** * Adds another nested where statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * - * @param Closure(WhereGroupBuilder):void $callback + * @param Closure(WhereGroupBuilder):void $callback * @param 'AND'|'OR' $operator * * @return self */ public function whereGroup(Closure $callback, string $operator = 'AND'): self { + /** @var WhereGroupBuilder $groupBuilder */ $groupBuilder = new WhereGroupBuilder($this->model); $callback($groupBuilder); @@ -181,7 +182,7 @@ public function whereGroup(Closure $callback, string $operator = 'AND'): self /** * Adds another nested `AND WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * - * @param Closure(WhereGroupBuilder):void $callback + * @param Closure(WhereGroupBuilder):void $callback * * @return self */ @@ -193,7 +194,7 @@ public function andWhereGroup(Closure $callback): self /** * Adds another nested `OR WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * - * @param Closure(WhereGroupBuilder):void $callback + * @param Closure(WhereGroupBuilder):void $callback * * @return self */ diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 754e4032a2..82fa44e22b 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -25,7 +25,7 @@ trait IsDatabaseModel /** * @return QueryBuilder */ - private static function queryBuilder(): QueryBuilder + protected static function queryBuilder(): QueryBuilder { return query(static::class); } @@ -33,73 +33,73 @@ private static function queryBuilder(): QueryBuilder /** * Returns a builder for selecting records using this model's table. * - * @return SelectQueryBuilder + * @return SelectQueryBuilder */ public static function select(): SelectQueryBuilder { - return self::queryBuilder()->select(); + return static::queryBuilder()->select(); } /** * Returns a builder for inserting records using this model's table. * - * @return InsertQueryBuilder + * @return InsertQueryBuilder */ public static function insert(): InsertQueryBuilder { - return self::queryBuilder()->insert(); + return static::queryBuilder()->insert(); } /** * Returns a builder for counting records using this model's table. * - * @return CountQueryBuilder + * @return CountQueryBuilder */ public static function count(): CountQueryBuilder { - return self::queryBuilder()->count(); + return static::queryBuilder()->count(); } /** * Creates a new instance of this model without persisting it to the database. */ - public static function new(mixed ...$params): self + public static function new(mixed ...$params): static { - return self::queryBuilder()->new(...$params); + return static::queryBuilder()->new(...$params); } /** * Finds a model instance by its ID. */ - public static function findById(string|int|PrimaryKey $id): self + public static function findById(string|int|PrimaryKey $id): ?static { - return self::get($id); + return static::get($id); } /** * Finds a model instance by its ID. Use through {@see Tempest\Router\Bindable}. */ - public static function resolve(string $input): self + public static function resolve(string $input): static { - return self::queryBuilder()->resolve($input); + return static::queryBuilder()->resolve($input); } /** * Gets a model instance by its ID, optionally loading the given relationships. */ - public static function get(string|int|PrimaryKey $id, array $relations = []): ?self + public static function get(string|int|PrimaryKey $id, array $relations = []): ?static { - return self::queryBuilder()->get($id, $relations); + return static::queryBuilder()->get($id, $relations); } /** * Gets all records from the model's table. * - * @return self[] + * @return static[] */ public static function all(array $relations = []): array { - return self::queryBuilder()->all($relations); + return static::queryBuilder()->all($relations); } /** @@ -110,11 +110,11 @@ public static function all(array $relations = []): array * MagicUser::find(name: 'Frieren'); * ``` * - * @return SelectQueryBuilder + * @return SelectQueryBuilder */ public static function find(mixed ...$conditions): SelectQueryBuilder { - return self::queryBuilder()->find(...$conditions); + return static::queryBuilder()->find(...$conditions); } /** @@ -125,11 +125,11 @@ public static function find(mixed ...$conditions): SelectQueryBuilder * MagicUser::create(name: 'Frieren', kind: Kind::ELF); * ``` * - * @return self + * @return static */ - public static function create(mixed ...$params): self + public static function create(mixed ...$params): static { - return self::queryBuilder()->create(...$params); + return static::queryBuilder()->create(...$params); } /** @@ -145,11 +145,11 @@ public static function create(mixed ...$params): self * * @param array $find Properties to search for in the existing model. * @param array $update Properties to update or set on the model if it is found or created. - * @return self + * @return static */ - public static function findOrNew(array $find, array $update): self + public static function findOrNew(array $find, array $update): static { - return self::queryBuilder()->findOrNew($find, $update); + return static::queryBuilder()->findOrNew($find, $update); } /** @@ -166,15 +166,15 @@ public static function findOrNew(array $find, array $update): self * @param array $find Properties to search for in the existing model. * @param array $update Properties to update or set on the model if it is found or created. */ - public static function updateOrCreate(array $find, array $update): self + public static function updateOrCreate(array $find, array $update): static { - return self::queryBuilder()->updateOrCreate($find, $update); + return static::queryBuilder()->updateOrCreate($find, $update); } /** * Refreshes the model instance with the latest data from the database. */ - public function refresh(): self + public function refresh(): static { $model = inspect($this); @@ -185,7 +185,7 @@ public function refresh(): self $primaryKeyProperty = $model->getPrimaryKeyProperty(); $primaryKeyValue = $primaryKeyProperty->getValue($this); - $new = self::select() + $new = static::select() ->with(...$loadedRelations->map(fn (Relation $relation) => $relation->name)) ->get($primaryKeyValue); @@ -209,14 +209,14 @@ public function refresh(): self /** * Loads the specified relations on the model instance. */ - public function load(string ...$relations): self + public function load(string ...$relations): static { $model = inspect($this); $primaryKeyProperty = $model->getPrimaryKeyProperty(); $primaryKeyValue = $primaryKeyProperty->getValue($this); - $new = self::get($primaryKeyValue, $relations); + $new = static::get($primaryKeyValue, $relations); $fieldsToUpdate = arr($relations) ->map(fn (string $relation) => str($relation)->before('.')->toString()) @@ -232,7 +232,7 @@ public function load(string ...$relations): self /** * Saves the model to the database. If the model has no primary key, this method always inserts. */ - public function save(): self + public function save(): static { $model = inspect($this); $model->validate(...inspect($this)->getPropertyValues()); @@ -275,7 +275,7 @@ public function save(): self /** * Updates the specified columns and persist the model to the database. */ - public function update(mixed ...$params): self + public function update(mixed ...$params): static { $model = inspect($this); From 06302c7cfd0ad28613e54b01fc01e6949f1ddd4f Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 17:25:13 +0100 Subject: [PATCH 133/134] refactor(database): improve generics for strings passed to query() --- .../src/QueryStatements/SelectStatement.php | 1 - .../QueryBuilderGenericsTypeAssertions.php | 6 +++ phpstan.neon.dist | 4 ++ ...ueryFunctionDynamicReturnTypeExtension.php | 48 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/QueryFunctionDynamicReturnTypeExtension.php diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index b722f60d88..926b5afc52 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -2,7 +2,6 @@ namespace Tempest\Database\QueryStatements; -use Stringable; use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\QueryStatement; diff --git a/packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php b/packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php index 8a6b3b6545..50b835e24e 100644 --- a/packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php +++ b/packages/database/tests/TypeInference/QueryBuilderGenericsTypeAssertions.php @@ -97,3 +97,9 @@ final class ChildStubModel extends ParentStubModel \PHPStan\Testing\assertType('Tempest\\Database\\Tests\\TypeInference\\ChildStubModel|null', ChildStubModel::findById(1)); \PHPStan\Testing\assertType('Tempest\\Database\\Tests\\TypeInference\\ChildStubModel|null', ChildStubModel::get(1)); \PHPStan\Testing\assertType('array', ChildStubModel::all()); + +$tableQueryBuilder = query('stub_models'); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\QueryBuilder', $tableQueryBuilder); +\PHPStan\Testing\assertType('Tempest\\Database\\Builder\\QueryBuilders\\SelectQueryBuilder', $tableQueryBuilder->select()); +\PHPStan\Testing\assertType('object|null', $tableQueryBuilder->select()->where('id', 1)->first()); +\PHPStan\Testing\assertType('array', $tableQueryBuilder->select()->where('id', 1)->all()); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8810884dae..c7f22f37ed 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,10 @@ services: class: Tests\Tempest\PHPStan\LazyReadWritePropertiesExtension tags: - phpstan.properties.readWriteExtension + - + class: Tests\Tempest\PHPStan\QueryFunctionDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension parameters: level: 5 tmpDir: .cache/phpstan diff --git a/tests/PHPStan/QueryFunctionDynamicReturnTypeExtension.php b/tests/PHPStan/QueryFunctionDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..1675a5872c --- /dev/null +++ b/tests/PHPStan/QueryFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,48 @@ +getName() === 'Tempest\\Database\\query'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + if (count($functionCall->getArgs()) !== 1) { + return null; + } + + $constantStrings = $scope + ->getType($functionCall->getArgs()[0]->value) + ->getConstantStrings(); + + if (count($constantStrings) !== 1) { + return null; + } + + if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { + return null; + } + + return new GenericObjectType(QueryBuilder::class, [new ObjectWithoutClassType()]); + } +} From a6aa54a547ddb72819b42da7cebae2ad5acc9e4f Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 09:07:21 +0100 Subject: [PATCH 134/134] refactor(database): remove DatabaseConfig from Connection interface --- packages/database/src/Connection/Connection.php | 5 ----- packages/database/src/GenericDatabase.php | 3 +++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/database/src/Connection/Connection.php b/packages/database/src/Connection/Connection.php index 4b1b5011de..afcb58aab6 100644 --- a/packages/database/src/Connection/Connection.php +++ b/packages/database/src/Connection/Connection.php @@ -5,14 +5,9 @@ namespace Tempest\Database\Connection; use PDOStatement; -use Tempest\Database\Config\DatabaseConfig; interface Connection { - public DatabaseConfig $config { - get; - } - public function beginTransaction(): bool; public function commit(): bool; diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index e91004e9c4..06d0184027 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -10,6 +10,7 @@ use Tempest\Database\Builder\QueryBuilders\BuildsQuery; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Connection\Connection; +use Tempest\Database\Connection\PDOConnection; use Tempest\Database\Exceptions\QueryWasInvalid; use Tempest\Database\Transactions\TransactionManager; use Tempest\Mapper\SerializerFactory; @@ -17,6 +18,8 @@ use Throwable; use UnitEnum; +// TODO: add DatabaseConnection to the Connection interface instead (4.x) +/** @property PDOConnection $connection */ final class GenericDatabase implements Database { private ?PDOStatement $lastStatement = null;