diff --git a/AGENTS.md b/AGENTS.md index 2ad8957b2..4c81718d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,7 +215,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan - **Prefer union types over `mixed` when all types are known** — `mixed` is only for truly unconstrained values or cases that cannot be safely narrowed after control-flow analysis. - **Type decisions must be evidence-based** — check corresponding Laravel/Hyperf signatures and docblocks as a reference, then trace the real control flow through method bodies across all callers and callees to confirm the types are correct. - **Fail fast with framework and PHP exceptions** — don't add guards, wrapping, or defensive checks unless they handle a reachable invalid case or materially improve the error, and never swallow exceptions. If code would fail anyway (e.g. null passed to a typed parameter), let it fail naturally instead of adding a check that throws a custom exception — the stack trace is enough to diagnose. -- **Mark intentionally unsupported methods** — when a public method must be retained but intentionally rejects every call, mark it with `#[Unsupported]` from `Hypervel\Support\Attributes`, passing the supported alternative as its message when one exists. Exclude base defaults that supporting implementations override, extension hooks, deliberate control-flow throws, and conditional rejections. +- **Mark intentionally unsupported methods** — when a public method must be retained but intentionally rejects every call, mark it with `#[Unsupported]` from `Hypervel\Support\Attributes`, passing the supported alternative as its message when one exists. Exclude base defaults that supporting implementations override, extension hooks, deliberate control-flow throws, and conditional rejections. PHPStan reports calls that reach a marked method. - **Use semantic column types in migrations** — `uuid()` / `foreignUuid()` / `uuidMorphs()` for UUIDs, `ipAddress()` / `macAddress()` for network addresses, and `jsonb()` for JSON the database queries, indexes or validates. Use `text()` for opaque serialized values that are only read back whole. PostgreSQL gets the real types (`jsonb`, `uuid`, `inet`, `macaddr`); the other supported databases fall back automatically to compatible types. - **No arbitrary string lengths** — use `->string('name')`, not `->string('name', 100)`. Don't invent limits; specify a length only when the domain or protocol defines one — exact (UUID: 36, ULID: 26, sha-256 hex token: 64) or a defined maximum (IPv6 address: 45). - **No database enums** — use string columns plus PHP enums. Adding a value to a database enum requires a migration. @@ -300,20 +300,13 @@ $this->app->singleton('auth', fn ($app) => new AuthManager($app)); $this->app->singleton(FormatterInterface::class, DefaultFormatter::class); ``` -**3. Abstract and concrete are the same class — do not bind merely to share it.** Hypervel's container auto-singletons unbound concrete classes on first resolution. An explicit `singleton(Foo::class)` is redundant unless the class declares an intrinsic fresh lifetime through `Transient` or `SelfBuilding`: - -```php -// Wrong: redundant; auto-singleton handles this. -$this->app->singleton(BroadcastManager::class); - -// Correct: do not bind it. The first make(BroadcastManager::class) auto-singletons it. -``` +**3. Abstract and concrete are the same class — do not bind merely to share it.** Unbound concrete classes without a different declared lifetime are auto-singletons. Explicit registration can change lifetimes, `bound()`, optional constructor injection and rebinding; retain it when those effects are intended. ### Aliases and canonical keys Before binding core framework services, check `Application::registerCoreContainerAliases()`. -If the abstract or concrete participates in the alias table, choose the binding key carefully. `bind()` and `singleton()` store bindings under the exact abstract key passed; `resolve()` resolves aliases before lookup. Binding an alias instead of the canonical key can orphan the binding. +If the abstract or concrete participates in the alias table, choose the binding key carefully. `bind()` and `singleton()` store bindings under the exact abstract key passed; `resolve()` resolves aliases before lookup. Binding an alias removes its mapping to the canonical key; the two keys can then resolve differently. When adding a core alias, use the string key as the canonical abstract: @@ -395,7 +388,7 @@ Decide where state lives before writing code: - `#[TestsOnly]` — test fakes, swaps, and overrides - `#[BootOrTestsOnly]` — configuration or cleanup allowed in either context - Use `whenNotDefault: 'parameter'` only when the restriction applies to values differing from that parameter's declared default, `whenProvided: 'parameter'` when it applies whenever the argument is passed, and `whenShared: true` when it applies only to a worker-shared receiver. Keep a concise docblock explaining the runtime harm. If an API is intended for runtime use, fix unsafe shared-state behavior through a peer second opinion rather than restricting it to boot or tests. + PHPStan checks marked calls in recognized runtime contexts. Use `whenNotDefault: 'parameter'` only when the restriction applies to values differing from that parameter's declared default, `whenProvided: 'parameter'` when it applies whenever the argument is passed, and `whenShared: true` when it applies only to a worker-shared receiver. Keep a concise docblock explaining the runtime harm. If an API is intended for runtime use, fix unsafe shared-state behavior through a peer second opinion rather than restricting it to boot or tests. - **Assess static caching opportunities** — if a path repeatedly computes expensive stable metadata and worker-lifetime static caching would be a clear win, reach consensus with your peer agent through a second-opinion loop on what to cache, the expected benefit, bounded memory use, and correctness across requests before implementing. Classes that use static caching need a `flushState()` method for test cleanup — see "Static state and test cleanup" under Writing Tests. diff --git a/composer.json b/composer.json index 2a67c99f6..ed35d6172 100644 --- a/composer.json +++ b/composer.json @@ -385,7 +385,9 @@ "tests/PHPStan/Unit/data" ], "exclude-from-classmap": [ - "tests/PHPStan/**/*-fixed.php" + "tests/PHPStan/**/*-fixed.php", + "tests/PHPStan/Rules/data/request-dependent-bindings.php", + "tests/PHPStan/Rules/data/request-dependent-bindings-local-request.php" ], "files": [ "tests/Database/Fixtures/MigrationCreatorFakeMigration.php" diff --git a/docs/plans/2026-11-12-hypervel-phpstan-rules.md b/docs/plans/2026-11-12-hypervel-phpstan-rules.md new file mode 100644 index 000000000..aff51ce3d --- /dev/null +++ b/docs/plans/2026-11-12-hypervel-phpstan-rules.md @@ -0,0 +1,809 @@ +# Hypervel PHPStan Rules (PR 2) + +Branch: `feature/phpstan-hypervel-rules`. This PR builds on PR 1 (`docs/plans/2026-11-12-hypervel-attributes.md`, merged into `0.4`): the `hypervel/attributes` package, converted markers, `#[Unsupported]` markers, and `WorkerStateHelper::hasLifetimeAttribute()`. +- Commit the runtime changes (section 1) separately from the analysis work, so they can be reviewed and reverted on their own. + +Owner-approved: +- `Container::resolvedShared()` on the container contract, with facade roots from non-shared bindings becoming coroutine-local; +- binding `date` in `FoundationServiceProvider`, so a `Date` facade subclass overriding `DEFAULT_FACADE` needs an explicit `date` binding; +- narrowing the connection resolver contract to `Connection` (section 1.5); +- config value types only for the application's repository (section 3.9); +- the `AGENTS.md` edits in section 6. + +After implementation and signoff, show the owner each section 6 `AGENTS.md` change before and after in chat. + +## 1. Runtime changes + +### 1.1 `Container::resolvedShared()` + +**Why.** Facades must cache only resolutions the container keeps for the worker. `isShared()` reports explicit lifetimes but not auto-singletons (`$autoSingletons`, `Container.php:1340`). Identity checks are wrong, because a `bind()` factory may select between two singletons per coroutine. + +The contract (`src/contracts/src/Container/Container.php`) gets the method beside `isScopedInstance()`. The container places it beside `resolvedScoped()`: + +```php +/** + * Determine if the container holds a worker-shared instance for the given type. + */ +public function resolvedShared(string $abstract): bool +{ + $abstract = $this->getAlias($abstract); + + return isset($this->instances[$abstract]) + || array_key_exists($abstract, $this->instances) + || isset($this->autoSingletons[$abstract]); +} +``` + +- An `instance()` registration over a scoped binding counts. It lives in the worker map, and `make()` returns it. +- `isShared()` is unchanged. + +**Tests**, next to the `resolvedScoped()` tests: +- singleton, `instance()` (including `null`), auto-singleton and alias → true; +- `bind()`, a `Transient` class and a `bind()` factory returning a singleton → false; +- an `instance()` over a scoped binding → true. + +### 1.2 Facade roots follow the container lifetime + +**Why.** `Facade::resolveFacadeInstance()` caches every root except scoped ones and `$cached = false` facades in the worker-wide `$resolvedInstance`. So a facade over a `bind()`/`Transient` service keeps one request's object for every later request. Laravel under FPM resolves it once per request; the coroutine is Hypervel's equivalent. + +New state class `src/support/src/Facades/FacadeState.php` (AGENTS "…State classes"): + +```php +class FacadeState implements NonCopyableContext +{ + /** + * The coroutine-local facade roots, keyed by accessor, with the generation each was resolved in. + * + * @var array + */ + public array $roots = []; +} +``` + +`NonCopyableContext` means copied child coroutines resolve their own roots, and concurrent coroutines never share a fresh object. + +In `Facade`: +- add `protected const string STATE_CONTEXT_KEY = '__support.facade_state';`; +- add `/** @var array */ protected static array $coroutineAccessors = [];`, the accessors with coroutine-local roots and their current generation. + +```php +protected static function resolveFacadeInstance(string $name): mixed +{ + if (isset(static::$resolvedInstance[$name])) { + return static::$resolvedInstance[$name]; + } + + if (! static::$app) { + return null; + } + + if (static::$cached && isset(static::$coroutineAccessors[$name])) { + $root = CoroutineContext::get(self::STATE_CONTEXT_KEY)?->roots[$name] ?? null; + + if ($root !== null && $root[0] === static::$coroutineAccessors[$name]) { + return $root[1]; + } + } + + $instance = static::$app->make($name); + + if (! static::$cached || isset(static::$scopedAccessors[$name]) || static::$app->isScoped($name)) { + return $instance; + } + + if (static::$app->resolvedShared($name)) { + static::$resolvedInstance[$name] = $instance; + } elseif (static::$app->isScopedInstance($instance)) { + // A transient factory can return a scoped service. Remember its lifetime + // so later calls neither retain the object nor repeat the scoped-store scan. + static::$scopedAccessors[$name] = true; + } else { + $state = CoroutineContext::get(self::STATE_CONTEXT_KEY) + ?? CoroutineContext::set(self::STATE_CONTEXT_KEY, new FacadeState); + + $state->roots[$name] = [static::$coroutineAccessors[$name] ??= 0, $instance]; + } + + return $instance; +} +``` + +How the lookup behaves: + +- **Warm path.** Shared hits are unchanged. Uncached and scoped accessors do no extra lookup. +- **`$cached` gate.** The coroutine lookup checks `static::$cached`, because two facade classes may share an accessor with different `$cached` flags, and a coroutine root differs from what `make()` would return. The worker-cache read stays ungated, as in Laravel: it holds only shared instances, which `make()` returns anyway, and swaps. +- **Allocation.** A coroutine creates its `FacadeState` only when it has none (`CoroutineContext::set()` returns the value). +- **Invalidation:** + - `clearResolvedInstance($name)` also does `if (isset(static::$coroutineAccessors[$name])) { ++static::$coroutineAccessors[$name]; }`. + - `clearResolvedInstances()` also increments every `$coroutineAccessors` value. + - Later facade calls in every live coroutine and in the non-coroutine store then resolve again. References already held stay old. + - Generations only increase, so an old entry can't become valid again. + - Update both docblocks: they currently say concurrent coroutines may keep the previous instance; they should say later facade calls everywhere resolve again, and only references already held stay old. +- **Unchanged:** + - `setFacadeApplication()`. All four callers clear first: `RegisterFacades:18-19`, testbench `TestCase:229-230`, `CreatesApplication:356-357` and `AfterEachTestSubscriber:291-292`. + - `swap()`. It sets the worker cache, which is read first, plus an instance binding. + - The `isFake()` checks. + +**Docs:** +- `src/docs/facades.md:196`: + > A facade keeps the instance it resolves for as long as the container shares it: services the container shares, such as singletons and auto-singletons, for the lifetime of the worker, and services registered with `bind()` or marked `Transient` for the current request or job coroutine. Scoped services always resolve from the current coroutine, including when a `bind()` binding or factory returns a scoped instance. To resolve the service on every facade call, set `protected static bool $cached = false` on the facade. +- Remove the facade paragraph at `src/docs/porting-from-laravel.md:438` and the facade bullet in `src/support/README.md` "Differences From Laravel". Facades now follow the container's lifetimes, as Laravel's do per request, and the warning above line 438 already covers worker-lifetime auto-singletons. `$cached = false` behaves as in Laravel. + +**Tests** (`tests/Support/SupportFacadeTest.php`). Existing tests already cover `$cached = false`, scoped facades including `bind()` delegates, replicated contexts, clearing, swap and cached-once. Add only: +- a `bind()` accessor that's consistent within a coroutine and isolated across concurrent coroutines (`parallel()` + `usleep()`). The same test first resolves it outside any coroutine, as boot code does, and asserts the request roots aren't that boot root, so a non-coroutine resolution is never promoted to the worker cache; +- a `bind()` factory selecting between two singletons per coroutine, so each coroutine sees its selection; +- a copied child coroutine resolving its own root; +- clearing with two live coroutines, after which both resolve again; +- a `$cached = false` facade sharing an accessor with a cached facade, which never returns the other's coroutine root. + +### 1.3 `date` binding and the `Date` facade + +**Why.** `Date::resolveFacadeInstance()` (`Facades/Date.php:126`) calls the tests-only `swap()` when `date` is unbound, and nothing binds `date`. So the first `Date::` call, usually in a request, registers a container instance at runtime. There's no concurrency defect: the construction doesn't yield. + +- `FoundationServiceProvider::register()`: `$this->app->singletonIf('date', DateFactory::class);`, so an earlier binding wins. `date` isn't in the alias table, so a string concrete is correct (AGENTS binding pattern 2). +- `Date::resolveFacadeInstance()` keeps its condition (`static::$app === null || ! static::$app->bound($name)`), so it still works with no app or a plain container. It replaces `static::swap(new $class)` with `static::$resolvedInstance[$name] = new (static::DEFAULT_FACADE);`, writing nothing to the container. +- Tests (`tests/Support/DateFacadeTest.php`): + - a booted app resolves `date` as a `DateFactory` singleton; + - a plain container without the binding gets the default factory and `bound('date')` stays false; + - an existing `date` binding wins over the provider's. +- Docs: add a bullet to `src/support/README.md` "Differences From Laravel" (owner-approved difference): "The framework binds `date` to `DateFactory`, so a `Date` facade subclass's `DEFAULT_FACADE` only applies while `date` is unbound. In an application, bind `date` to your factory instead." + +### 1.4 Callback typing (PHPDoc only) + +- `Filesystem\Concerns\InteractsWithPooledFilesystem`, each with `@return T`: + - `withDriver()`: `@template TDriver of FilesystemOperator`, `@param Closure(TDriver): T $callback`; + - `withAdapter()`: `@template TAdapter of FlysystemAdapter` (League's, imported under that alias), `Closure(TAdapter): T`; + - `withClient()`: an unbounded `@template TClient`, `Closure(TClient): T`. + - Callbacks may narrow the parameter to the disk's concrete driver, adapter or SDK client (`League\Flysystem\Filesystem`, League's `AwsS3V3Adapter`, `S3Client`). A fixed parameter type rejects those, so the template takes the callback's own type. Untyped callbacks get the bound; clients share no useful bound, so theirs stays `mixed`. `types/Filesystem/Filesystem.php` covers both forms. +- `Filesystem\FilesystemAdapter::withDriver()`/`withAdapter()`: the same types. It has no `withClient()`. +- `RedisProxy::withPinnedConnection()`, `RedisStore::withPinnedConnection()`: `@param callable(): T $callback`, `@return T`. +- `QueuePoolProxy::withConnection()`: add `@template T`, with `Closure(SqsQueue): T` and `@return T`. +- `Connection::withPinnedSession()` is already typed. +- Run `composer analyse` and fix newly exposed caller types at their source. + +### 1.5 Connection resolver contract + +**Why.** `ConnectionResolverInterface::connection()` declared `ConnectionInterface`, but `Model::resolveConnection()`, `Migrator::resolveConnection()` and `DatabaseManager::reconnect()` return `Connection` and carried ignores for it. Built-in and `DB::extend()` connections all come from `makeConnection(): Connection`, so a resolver returning anything else already breaks Eloquent. Laravel's contract documents `ConnectionInterface`, so this is a documented difference. + +- Narrow to the driver-neutral `Connection`, never `PdoConnection`: + - `ConnectionResolverInterface::connection()` and `CachedConnectionResolver::getResolvedConnection()`; + - `ConnectionResolver`'s `connection()`, `getHeldConnection()`, private context reader and stored-instance checks; + - `SimpleConnectionResolver::connection()`, and `DatabaseManager`'s `connection()`, `getHeldConnection()` and `resolveConnectionDirectly()`; + - Capsule `connection()`/`getConnection()`, and the testing `DatabaseConnectionResolver`, including its retained connections; + - the `DB` facade's `connection`/`getHeldConnection` tags. Its `@mixin ConnectionInterface` stays, since it supplies `transactionLevel()`'s impurity. +- Remove what the types make redundant: the `Model`, `Migrator` and `reconnect()` ignores, the manager's `@var Connection` casts, and `instanceof Connection` checks on values already typed `Connection`, including the `forgetRecordModificationState()` guards in the `RefreshDatabase` and `DatabaseTransactions` testing traits. +- Narrow the queue accessors that return a resolver connection, `DatabaseQueue::getDatabase()` (Laravel documents `Connection`) and `DatabaseOutboxRepository::getConnection()`, to `Connection`, and remove their `instanceof Connection` guards before `supportsSkipLocked()`. Independently supplied `ConnectionInterface` inputs, such as outbox `insert()`, stay. `testLockForPoppingUsesTheConnectionCapability()` covers both `supportsSkipLocked()` results; the interface-only fallback test goes, since a resolver can't return such a connection. +- Test doubles crossing the narrowed boundary mock `Connection`; ones passed straight to a `ConnectionInterface` parameter stay. Non-PDO cases keep a base `Connection` double. +- Docs: a `src/database/README.md` "Differences From Laravel" bullet and a porting-guide database paragraph. Custom resolvers return a `Connection`, and test doubles returned from `connection()` mock `Connection`. +- It's a runtime change, so it gets its own commit, including the queue accessors and their tests. + +## 2. Shared analysis infrastructure + +### 2.1 Facade roots + +New `src/phpstan/src/Support/FacadeHelper.php`, an `@internal` `#[AutowiredService]`, memoized per facade class. It finds the method a facade static call runs without running application code: no accessor or root is executed. `FacadesMethodsExtension` keeps its existing booted-root lookup for methods a facade's PHPDoc omits; the new rules don't use it. + +- `getAccessor(ClassReflection $facade): ?string` reads the nearest `getFacadeAccessor()` declaration through `@cachedRichParser`, as `ConstructorParser` does. Only a body that is a single `return` of a string literal or `X::class` gives an accessor; anything else is unknown. +- `getRootClass(ClassReflection $facade): ?string` gives the exact class the root is built as, or null when that isn't known. Aliases are never evidence: they name a container key, not the class its binding returns. + - **Resolution path.** A `resolveFacadeInstance()` or `getFacadeRoot()` declared by the facade, or a parent below `Facade`, makes the root unknown. The one exception is the framework's own `Date::resolveFacadeInstance()`, when no subclass overrides it: with `date` bound it resolves normally, and without the binding the root is the facade's `DEFAULT_FACADE` constant. + - **Booted container**, starting from the accessor's canonical key (`getAlias()`) and following each step's own key: + - a booted instance → its class; + - a binding registered with a class name (`WorkerLifetimes::getBindingConcrete()`, made public static) → resolve that class's key the same way, so a concrete that is itself bound follows its binding; a concrete equal to its abstract → that class if it's buildable; + - a closure binding whose body is exactly `new X(...)`, as an arrow function or a single `return` → `X`. This is the AGENTS binding pattern 1 form, used for keys such as `cache`, `session`, `log`, `redis` and `mail.manager`; multi-statement factories such as `events` and `router` are covered only when the boot already resolved them; + - no binding and a buildable class → that class; + - anything else, or a cycle → unknown. + - **Only exact construction counts.** These rules identify the root; they don't interpret factories, extenders or callbacks. + - `Container::resolve()` applies registered extenders after construction (`Container.php:1299`), and an extender can return another object. So a not-yet-resolved chain with an extender registered for any of its keys (the container's `$extenders`, read like the other container state) is unknown. A booted instance stays evidence, since `extend()` replaces a stored instance directly. + - A class reached without a factory that implements `SelfBuilding` is unknown: the container builds it through `newInstance()`, which may return a subclass. A literal `new X(...)` in a factory is still exact. + - "Buildable" uses `WorkerLifetimes`' rules (a concrete class without `#[Bind]`/`#[BindWhen]`), shared as public static `WorkerLifetimes::isBuildableClass(ClassReflection)`; each caller looks the class up itself, so `WorkerLifetimes` still records it as a dependency. The `SelfBuilding` check stays local to `FacadeHelper`, so the helper's meaning doesn't change for `WorkerLifetimes`. + - **Closure source matching.** `ReflectionFunction` gives the file and the start and end lines but no column. Parse the file with `@cachedRichParser` and take the closures and arrow functions with exactly that line range. Anything other than exactly one candidate is unknown, so two factories on one line never resolve. `X` must be a named class resolved through the file's namespace and imports. `self`, `static`, `parent`, dynamic class expressions and anonymous classes are unknown. +- `getMethod(ClassReflection $facade, string $name): ?ExtendedMethodReflection`: the facade's own public static native method (`swap()`, …), else the root class's public native method. A `__callStatic()` declared outside `Facade`, `Hash` and `Crypt` can forward anywhere, so it gives null after the facade's own methods; `Hash`'s and `Crypt`'s forward to the root as `Facade`'s does, keeping sensitive arguments out of stack traces. +- **Tests** (through `UnsupportedCallRule`, which reads roots without contexts): + - a facade over a single-expression `new X` factory resolves; + - the same factory with an extender registered is unknown, as are a `SelfBuilding` root and a factory sharing its line with another closure; + - a facade with its own `__callStatic()` is unknown for root methods, while its own marked static method is still reported; + - a class-name binding chain resolves to its final class. +- Every call, memoized or not, tracks `RuntimeInputs::track($scope)`, `trackSources($scope, $facade, Facade::class)` for the accessor body, and `trackClass()` for the root and any class whose declaration made it unknown. A binding closure comes from a file the boot loaded, which `track()` already covers. + +### 2.2 Lifetime queries on `WorkerLifetimes` + +- `sharesAbstract(string $abstract): TrinaryLogic` maps `getLifetime(getAlias($abstract), true)`: shared → yes, unknown → maybe, scoped or fresh → no. + - `computeLifetime()` keeps a binding's own lifetime when the built class is unknown: the container caches whatever a shared or scoped closure factory returns, so it's shared or scoped; a `bind()` factory may return a singleton, so it's unknown. An unbuildable same-class concrete or an unknown concrete chain follows the same rule. + - Yes therefore covers booted instances (including one over a scoped binding), shared bindings with closure factories, auto-singletons and a `bind()` to a shared class. +- Injected parameters: `$configParameters` becomes `$injectedParameters`, mapping `class::method::parameter` (declaring class; `__construct` for constructors) to the canonical abstract the container injects, or null when unknown. + - Sources: booted controller actions (`discoverRoute()`; route-bound, defaulted and contextual-attribute parameters record null), job handlers (`discoverJob()`; a contextual class name replaces the dependency, a contextual closure records null), and constructors run by `build()` (`resolveConstructorParameter()` returns the outcome with the injected abstract; unknown arguments record null). A closure factory, shared or contextual, records null for every parameter of its abstract's constructor, since it may build that class with any arguments. + - Merge: every call records through `recordInjection()`. Calls that inject different abstracts, or an unknown one, leave the entry null, so an inherited action, handler or constructor gets evidence only when every route, job or build agrees. The result doesn't depend on traversal order. + - `isConfigParameter()` checks `isGlobalConfig()` of the recorded abstract. `sharesInjectedParameter($class, $method, $parameter): TrinaryLogic` gives no when unrecorded, maybe when null, else `sharesAbstract()` of the abstract. +- `isContainerBuilt(string $class, string $method)`: whether the container built every instance the method handles an operation for, so promoted properties hold what it injected. + - `addContext()` records each context's receiver class per method declaration, and helpers inherit their caller's receivers through propagation. An inherited handler or helper is analyzed in its declaring class's scope, so the receivers decide, not that class. + - A receiver counts when it isn't `ShouldQueue`, or when the dispatcher resolves it as a listener or observer (section 2.3). A serialized job is restored from its payload even when other code also resolves it from the container. + - Every receiver must count, so a base method that a serialized-job subclass also runs gives no evidence. A method no booted handler reaches is judged by its own class. +- The new queries are used only by section 3.1 and the config-write check. + +### 2.3 Listener and operation contexts in `WorkerLifetimes` + +`discoverListeners()` already makes listener classes roots. Extend it to record execution contexts per registered handler, as an operation context separate from lifetime roots: + +- **Registries.** The dispatcher keeps four: `listeners` (exact names), `wildcards` (`listen('x*')`), `observers` (`observe()` with exact names) and `observerWildcards` (`observe('x*')`, and `listen('*')`, which the dispatcher routes there). The first two are queueable listeners; the observer registries hold passive observers, which `makeObserver()` resolves from the container and calls synchronously. All four and `compiledWildcardPatterns` are read through closure access. +- **Handler:** `Class@method` strings, `[Class, 'method']` arrays, otherwise `handle()`; for a listener, a method the class lacks becomes `__invoke()` (`Dispatcher::createClassCallable()`). Observers have no `__invoke()` fallback. The receiver comes from `makeHandler()`, so a bound subclass and the declaring class follow the existing handling. +- **Queued handlers:** a `ShouldQueue` listener, including a `ShouldQueue` `#[ObservedBy]` observer (registered as a listener), gets a `job` context for its registered method whatever its event, since `CallQueuedListener::handle()` resolves it from the container in the queue worker and calls that method. A passive observer is never queued, so it keeps its event's context, including for a `ShouldQueue` class's `handle()`. Either way the built class is recorded as a container-built receiver for `isContainerBuilt()`. The event payload is never injected-parameter evidence. +- **Registration wins over class-only inference.** The constructor's `JOB_METHODS` loop skips a queued class's method that a registration of that same class already gave a context (a snapshot of `contextReceivers` before the loop), so another class inheriting the method, such as a serialized-job subclass, still adds its own receiver, and `WorkerStateAnalysis::getContext()` prefers the lifetimes context over `getFunctionFacts()`' class-only `job` for `ShouldQueue` handlers, keeping that inference where there's no registration. +- **Cloned listeners.** `CallQueuedListener::setJobInstanceIfNecessary()` clones the resolved listener when it uses `InteractsWithQueue`, then calls the handler on the clone. The clone is shallow: its own properties and arrays are copied for that job, but objects they hold are the container instance's. The handler method and the helpers it reaches on that receiver keep the container's shared flag in their context, since they run on a copy of a shared instance, and record the receiver as a copy (`copies[key][receiver]`). `addContext()` takes the copy kind: null, `COPY_CLONE` here, or `COPY_WITHOUT_CONSTRUCTOR` for the option instances below. The kind travels through propagation, and the visited key includes it. `COPY_WITHOUT_CONSTRUCTOR` also marks the method `withoutConstructor`, which makes `isContainerBuilt()` false: a method of the class can initialize a readonly promoted property itself, so it may not hold what the constructor would have been given. "Copy" is the analysis's shared category for both; PHP doesn't clone the option instance. A shared context that isn't a copy marks its method `direct`. Methods on the container's instance stay shared and are seeded as their own entries: `failed()` through the `JOB_METHODS` loop, the dispatcher's `shouldQueue()` (`handlerWantsToBeQueued()`) with the event's context, which may be unknown, and `CallQueuedListener::uniqueVia()`/`debounceVia()` with `job`, only for a `ShouldBeUnique` or debounced listener. The unique and debounce locks call those two when the dispatcher acquires them and again when they're released. + - **Option instances.** The dispatcher reads the job's options from an instance of the registered class made with `newInstanceWithoutConstructor()` (`createListenerAndJob()`), not from the container, so a binding to a subclass doesn't apply to it. Its option methods are seeded on the registered class with the event's context, unshared, as `COPY_WITHOUT_CONSTRUCTOR` visits: + - `viaConnection()`, `viaQueue()` and `withDelay()` (`queueHandler()`); `backoff()`, `retryUntil()`, `tries()`, `messageGroup()` and `middleware()` (`propagateListenerOptions()`); + - `deduplicator()`, or `deduplicationId()` when the class has no `deduplicator()` (the SQS queue calls it when pushing); + - `uniqueId()` and `uniqueFor()` for a `ShouldBeUnique` class, and `debounceId()` for a debounced one. + + The instance holds only property defaults, so its copy-only methods' writes to its own properties and arrays stay with it, through the copied-writes handling below. Static writes, restricted calls and swallowed cancellation are checked in the event's context. A helper it shares with a shared receiver's method, such as `shouldQueue()`, stays shared. + - **Debounced listener**, as the dispatcher's `getAttributeValue()` reads it from outside the listener: a `#[DebounceFor]` attribute (`ReflectionHelper::findClassAttribute()`; its value is never null), or a public non-static `debounceFor` property with a non-null default, `0` included. A default isn't a declaration, so `getDefaultSources()` returns each class whose default was read, including when it disables debouncing, and `WorkerStateAnalysis::track()` tracks them with `trackSources($scope, $class, null)`. + + A context entry may therefore have an unknown operation (`addContext()` takes a nullable context and keeps a known one), and the visited key includes the operation so each one propagates. `addHandlerContext()` records every handler visit whose receiver has the method, including a passive observer of an event with no known operation: `Dispatcher::makeObserver()` calls the resolved instance synchronously, so that visit is `direct` and keeps a queued registration of the same class from making the method copy-only. No operation is invented for it, and it counts as a registration in the `JOB_METHODS` snapshot like a known-context observer. After propagation, the constructor computes the copy-only methods once, so every class it reads is a dependency like the other lifetime decisions. A method is copy-only when it has copy receivers, isn't `direct`, and every shared receiver that is its class or a subclass and runs its declaration (`resolveHelper()`, so a parent's private method counts for subclasses) is one of its copy receivers. So a concrete parent the container also shares on its own, or an abstract parent with a shared sibling subclass, stays shared. `runsOnCopy(class, method)` reads that map. + - **Copied writes.** Section 3.1's `WorkerStateRule::checkWrite()` treats a copy-only method's copied writes as unshared for the unbounded-cache, retained-state and handler-write branches. `WorkerStateWriteCollector` records `copied`: the write replaces the property itself, or every offset level it writes or unsets is definitely not an object (an array, a string, or null that the write turns into an array). A write into an object the property holds or may hold at any level, such as a `Collection` or an `array|Collection` union, stays shared. Each write is judged on its own, so a write into an object the method itself just put in the property is still checked; following property replacements would need flow analysis of the method body. + - Static writes, constructor captures and configuration writes are unchanged. Restricted calls still see a clone's promoted properties as shared, but not those of a method an option instance reaches (`withoutConstructor`). +- **Context sources**, where any registration proves runtime use. An exact name takes its event's context. A wildcard pattern takes every distinct context of the framework events below that it matches (the dispatcher's compiled pattern), plus `operation` when it starts with `eloquent.`; a handler with several contexts is recorded once per context. + - `request`: `HttpServer\Events\RequestReceived`, `RequestHandled`, `RequestTerminated`, `ResponseSent`, `RequestCanceled`, and `Routing\Events\Routing`, `RouteMatched`, `PreparingResponse`, `ResponsePrepared`. + - `job`: `Queue\Events\JobProcessing`, `JobProcessed`, `JobFailed`, `JobExceptionOccurred`, `JobAttempted`, `JobReleasedAfterException`, `JobTimedOut`. + - `operation`: `Core\Events\OnReceive`, `OnPacket`, `OnTask`, `OnFinish`, `OnPipeMessage`, `OnConnect`, `OnClose`. Each is dispatched per server operation from its `Core\Bootstrap\*Callback`. + - `operation` for Eloquent observer methods: they can run during any operation that persists models, including requests, jobs, commands and seeders. Messages say "during an operation", not HTTP. + - Listeners registered under `eloquent.*` events in the booted dispatcher (`observe()` in a provider). + - `#[ObservedBy]` observers. `HasEvents::bootHasEvents()` registers them lazily at a model's first use, so the booted dispatcher lacks them. `WorkerStateClassCollector` records each concrete `Model` subclass's effective observers: the `#[ObservedBy]` class-strings of the model and its ancestors below `Model` (as `resolveObserveAttributes()` merges them), with its observable events (the standard `getObservableEvents()` names plus its inherited or redeclared `$observables` default). Property defaults aren't declarations, so it tracks `RuntimeInputs::trackSources($scope, $model, Model::class)`. + - A model overriding `bootHasEvents()`, `observe()`, `resolveObserveAttributes()`, `registerObserver()` or `getObservableEvents()`, or whose `$observables` default isn't a list of strings, records nothing; booted registrations still count. Runtime `setObservableEvents()`/`addObservableEvents()` changes aren't modeled. + - New `WorkerLifetimes::discoverObservers()` makes each observer a root through `makeHandler()` and gives `operation` to each event method the observer has natively (inherited included), as `registerObserver()` registers `Observer@event`. +- Unknown app events add nothing. Contexts propagate to `$this` helpers through the existing helper-call propagation. +- **Tests** (`WorkerStateRuleTest`): a request-event listener, a `Class@method` listener, a queued listener (job context), an `observe()` observer, direct and repeated `#[ObservedBy]`, an attributed abstract parent with a concrete child adding a custom event, an inherited observer method, a helper the event method calls (context) and an unused helper (none). A wildcard listener matching job events (job), a `listen('*')` observer, and a `ShouldQueue` passive observer's `handle()` (request context, not job, with a promoted repository write reported through `isContainerBuilt()`). An `InteractsWithQueue` queued listener whose handler keeps request state and a request-keyed cache, writes a string offset and an offset of a null `?array` property, and calls a helper writing a property (silent), with its `failed()` write and a request-keeping helper that both `handle()` and `shouldQueue()` call (reported); the existing queued listener without the trait stays reported. Writes through shared objects on the clone path: a request-valued offset into a `Collection` property, a scalar written two levels deep into an array of `Collection`s and a scalar offset of an `array|Collection` property in `handle()`, and a scalar `Collection` offset in a helper only `handle()` reaches (reported). An inherited helper of a concrete parent the container also shares, and of an abstract parent with a shared sibling subclass (reported). An `InteractsWithQueue` queued listener that is also a passive observer of an unknown event, whose `handle()` keeps the request (reported). + - Option instances (`worker-state-listener-options.php`, its own test method), on request-event queued listeners: `viaQueue()` writing a static property (reported) and a scalar, a request value and a request-keyed array entry to its own properties (silent); a request-keeping helper both `viaQueue()` and `shouldQueue()` call (reported). A registered class bound to a subclass that overrides `viaQueue()`: the registered class's request write is silent, the override's is reported. A static write in `uniqueId()` of a non-unique listener (silent) and of a `ShouldBeUnique` one (reported). Static writes in `deduplicationId()` (reported), and in a class with `deduplicator()`, in that (reported) and in its `deduplicationId()` (silent). Static writes in `debounceId()` with `#[DebounceFor]` and with a `debounceFor = 0` default (reported), and with a `null` default (silent). + - `RestrictedCallRuleTest`: a `viaQueue()` that initializes a readonly promoted registry with `??=` and calls a `whenShared` method on it (silent), beside the same call in a constructed listener's `handle()` (reported). +- These contexts feed `getContext()`, so `WorkerStateRule` gains them too. Re-run its tests and review any new reports. + +### 2.4 Collected data and the shared model + +`WorkerLifetimes` exists only in a `CollectedDataNode` rule, because it's built from every file's collected classes, resolutions, route actions and helper calls. Rules that need contexts or lifetimes therefore collect per-file facts and report from the collected data. + +- **Shared model.** Move the model building out of `WorkerStateRule::processNode()` (grouping the collected resolutions, singletons, queued classes, helper calls, route actions and reassigned parameters, then constructing `WorkerLifetimes`) into a new `Rules/Coroutine/WorkerStateAnalysis`, together with the rule's `getContext()`, `findAction()`, `getFunctionKey()`, `isReassigned()` and `isInjectedConfig()`. + - `WorkerStateHelper::analyze(CollectedDataNode $node): WorkerStateAnalysis` builds it once per node, keeping the last node and its analysis. The helper takes over `HasContainer` from `WorkerStateRule`. + - Each rule calls `WorkerStateAnalysis::track($scope)` after its queries. It tracks the booted application and the lifetime classes, which a query can add to. `describeContext()` moves there too, shared by the three rules. + - `WorkerStateRule`, `RestrictedCallRule` and `SwallowedCancellationRule` all use it. +- **Facts.** `WorkerStateCallCollector` keeps its one fact per call (resolution, route action, helper edge or config write). New facts come from their own collectors, so a call can be both, for example a route registration that is also a restricted call: + - New `RestrictedCallCollector` (on `CallLike`, under `checkRestrictedCalls`) records `{line, method, attribute, condition, may, whenShared, receiver, function}`. `condition` is the marker's parameter condition for the message, and `may` means it may not hold. `receiver` is section 3.1's evidence: `{abstracts}` (a facade accessor or a literal resolution), `{parameter}`, `{property: [class, property]}`, or null. Unmarked calls, calls whose parameter condition definitely doesn't hold, and configuration writes (section 3.1) aren't collected. + - New `SwallowedCancellationCollector` (on `TryCatch`, under `checkCancellationHandling`) records `{line, operation, function}` for each catch that section 3.17 reports once a context is known. +- **Enablement.** `WorkerStateHelper::isEnabled()` also covers `checkRestrictedCalls` and `checkCancellationHandling`, so the class, call and parameter collectors gather what the shared model needs when only a new check is on. New `hasStateChecks()` covers the four existing switches: `WorkerStateRule` and the write and config-offset collectors use it. + - Regression test: with the four worker-state switches off and `checkRestrictedCalls` on, a restricted call in a controller action's `$this` helper is still reported (its own neon config beside the rule tests). + +### 2.5 Rule conventions + +- Each rule is an `@internal` class with `#[AutowiredService]`, or `#[Conditional(attribute: new AutowiredService, if: '%switch%')]` for switched rules. It filters by node kind and method or function name before reflection. +- Rules use `RuleErrorBuilder` with the identifier and a tip pointing at the fix. +- Switches go in `extension.neon` `parameters` and `parametersSchema` (`bool()`). +- Every rule gets a `RuleTestCase` in `tests/PHPStan/Rules/...` with a `data/` fixture: violations, the allowed alternatives, and each documented exception. +- Run each test file immediately: `timeout 600 vendor/bin/phpunit -c phpunit.phpstan.xml.dist --no-progress `. +- Each `static-analysis.md` section states the pattern, a short example, the fix and the legitimate exception (ignore with a reason), in Laravel-docs prose. + +**Rules that identify calls** share these requirements: +- Creating a first-class callable (`$factory->globalOptions(...)`) isn't a call and is never reported. PHPStan passes rules a `*CallableNode` instead of the call node, so only code reading nested raw nodes (a statement's expression, a walked callback) checks `isFirstClassCallable()`. +- A call is matched by its resolved declaration, through nullsafe calls too, with PHP's case-insensitive names. PHPStan passes a nullsafe call to `CallLike` rules twice, as a virtual `MethodCall` and then as the original `NullsafeMethodCall`, so such a rule (`NoCoroutinesInTransactionsRule`) skips the top-level original; raw body walks still match it. A user override or custom implementation is judged by its own declaration, so its body is unknown; config, container and facade methods are recognized by exact declaring class, not by name or ancestry. A facade subclass's own native method is such an override. +- Arguments bind to parameters by name or position (`CallLike::getArg($name, $position)`). An unpacked argument at or before the parameter's position makes the binding unknown; it's never guessed positionally. + +**Variable writes.** `CoroutineRuleHelper::assigns()` decides whether a node writes a variable: `=`, `=&` or a compound assignment, a destructuring leaf, a `foreach` key or value target, or a catch binding. Destructuring uses `CoroutineRuleHelper::getTargetLeaves()`, a public static moved from `InspectsConnections`, which its trait callers use too. Its callers are the swallowed-cancellation rethrow and `$previous` forwarding checks and `#[BindWhen]`'s container parameter. + +**Cache dependencies** use the existing `RuntimeInputs` and scope tracking, never a new mechanism: +- booted state (bindings, aliases, routes, listeners, config, package mode, `bound()`): `RuntimeInputs::track()`; +- facade accessor bodies: `RuntimeInputs::trackSources($scope, $facade, Facade::class)`; +- queued listeners' `debounceFor` defaults (2.3): `trackSources($scope, $listener, null)`, whose base class is nullable to cover every ancestor; +- classes whose declarations decide a result (facade roots, lifetime classes, Socialite parents): `trackClass()`/`trackClassDependency()`; +- code read beyond declarations, which PHPStan's exported nodes don't cover. Track their files with `trackFileDependency()`: + - exception constructor chains (3.17, the files from `SourceFiles::forClass()`: constructor bodies only, so no property defaults); + - first-class-callable `#[BindWhen]` conditions (3.16), the declaration's file; + - declared default values (`whenNotDefault`; exported nodes only record that a default exists): `SourceFiles::forParameterDefault()`, the method's file and the files of the constants the default reads; + - attribute arguments reading constants from other files: exported nodes keep the argument expressions, not the constants' values. `SourceFiles::forAttributeArguments()` takes an optional method name for method attributes. It covers lifetime markers (3.1), `#[Unsupported]` messages (3.2) and each model ancestor's `#[ObservedBy]` (2.3). + +Warm-cache tests go in `tests/PHPStan/WorkerStateDependencyTest.php`, one per mechanism rather than per rule: a booted registration changing a lifetime, a facade accessor body, a constructor forwarding `$previous`, a declared default value read from another class's constant, and a queued listener's `debounceFor` default, inherited from a parent in another file so no collected data changes, going from `null` to `0` while its `debounceId()` is unchanged. `SourceFilesTest` covers the method-attribute lookup. +- `WorkerStateRule` reuse tests analyse once more before the edit, only with `reuseCollectedDataRuleErrors` on (without it the rule always runs). PHPStan 2.3.5's `ClassResultCacheValueExtension` puts the declaring file's path in a class's value, and a stub-backed class (`Redis`, `Swoole\Server`, an interface such as `Countable`) gets its stub path spelled differently depending on the source locator that found it. In the `WorkerStateDependency` fixture the stored values differ between a cold and a warm run, so the first warm run after a cold run reruns `WorkerStateRule` whatever changed, and a cold → edit → warm test passes without the tracking it means to prove. + +**Test sizes.** Each concrete `ResultCacheTestCase` subclass is `#[Large]`: they copy an application and run PHPStan in subprocesses, and PHPUnit doesn't inherit size attributes from a parent. `phpunit.phpstan.xml.dist` sets `timeoutForLargeTests="180"`; other tests keep the 60-second limit. `RuntimeInputsDependencyTest` deliberately runs its analyses in parallel, so its parameters cap them at two processes (`maximumNumberOfProcesses: 2`) rather than one per CPU under each ParaTest worker. + +**Testbench runtime copy.** A package analysis boots a Testbench copy of the skeleton that is deleted at exit. `Bootstrapper::copySkeleton()` copies or links the skeleton's `vendor`, or links the working package's when the skeleton has none. + +- Tracking `PackageManifest::$vendorPath`'s metadata by the copy's path made every warm run reanalyse each `track()` consumer (about a third of components). +- `RuntimeInputs::createKey()` instead tracks two things: + - `composer/installed.json` and `installed.php` under the skeleton's `vendor` and under the working vendor, each by its own spelling and including absence; + - the skeleton `vendor` directory's presence, through a new directory-presence entry in `RuntimeInputsResultCacheValueExtension`. An empty one stops the link to the working vendor. +- `Bootstrapper::runtimeVendorPath()` returns the working vendor path, stored and reset beside `runtimeSourcePath()`. `runtimeCopy()` resolves it with the other ownership fields. +- Only the copy's standard `/vendor` is replaced. Any other manifest vendor keeps the `sourcePath()` handling. +- `RuntimeInputsPackageDependencyTest` covers an unchanged rerun, a repointed working-vendor link whose old target remains, and an empty skeleton `vendor` added and removed. It also covers skeleton metadata appearing after the working vendor was used. + +### 2.6 Global variable writes + +**Why.** A global variable is shared by the whole worker like a static property, so `$GLOBALS['current_user'] = $user` in a request has the same cross-request harm as the static writes `WorkerStateRule` reports. PHPStan's scope doesn't say which variables are global (`GlobalHandler` only assigns `mixed`), and its `VariableAssignNode` for a `$GLOBALS` offset write carries only the whole array's new type. + +**How.** `WorkerStateGlobalWriteCollector` (under `checkStaticStateMutation`, `Node` filtered by kind) records: +- on raw nodes: the targets of `CoroutineRuleHelper::getWriteTargets()` (assignments, compound assignments, `=&`, destructuring, `foreach` keys and values, `catch` bindings), increments and decrements, and each offset or property `unset()` removes. A plain variable the node unsets or binds by reference (`getDetachedNames()`: `unset()`, `=&`, a by-reference `foreach` value or destructuring item, found by `getReferenceLeaves()`) is rebound, not written; an offset or property bound by reference is still written. Each `$GLOBALS`-rooted target is a `global` fact, and each other variable-rooted target given request state a `value` fact. State comes from a whole value only: an assignment's or `??=`'s expression, or the iterable's value or key type for a whole `foreach` value or key target. `$this` targets are skipped, since `$this` can't be bound to a global; +- arguments a by-reference parameter writes, joined from two sides without resolving callees. Raw calls (not first-class callables, and not PHPStan's original nullsafe call beside its virtual method call) give candidates: an argument rooted at `$GLOBALS` (from the call node) or at a bound name (from the body walk) is a fact with `argument: true`. PHPStan's own call handling gives the evidence: its `VariableAssignNode` or `PropertyAssignNode` for a by-reference argument writes a `NativeTypeExpr`, and any `VariableAssignNode` of `$GLOBALS` counts too, since an offset argument is reported like any offset write. Both carry the target key, and an argument fact counts only with evidence at the same key, without state. PHPStan writes a `NativeTypeExpr` elsewhere too (`foreach` values), so the position join is what ties the event to the argument. An offset of a bound name passed by reference (`sort($held['key'])`) isn't covered: PHPStan reports it exactly like `$held['key'] = …`, so telling them apart would need evidence for every offset write or callee resolution; +- on `InClassMethodNode`, `InFunctionNode` and `InClosureNode`: a walk of the function's own top-level statements in order. A top-level `global` binds its names; one under a condition binds nothing. So does a top-level reference assignment between a plain variable and global storage (`CoroutineRuleHelper::getStorageAlias()`): `$alias =& $GLOBALS['key']`, `$alias =& $bound` or an offset of it, or that storage `=& $local`. A property isn't followed, since a property can return storage other than its object's by reference. It's read before the statement detaches the left name, and a plain left side is only re-pointed (`global $held; $held =& $local;` doesn't make `$local` global). `CoroutineRuleHelper::getDetachedNames()` (`unset()` of the variable, `static`, a by-reference assignment to it) detaches a name. Closures, arrow functions, named functions and classes are skipped, since they have their own bindings. Each write to a bound name is a `bound` fact. + +Facts carry a target key: the enclosing class and function and the target's position. Trait code is collected under the using class's file with positions from the trait's own file, so the position alone isn't unique. `WorkerStateRule` gives a `bound` fact the state of the `value` fact with the same file and key, then reports `hypervel.staticStateMutation` with `STATIC_TIP`: "Global variable $GLOBALS['x'] keeps the current request, which the whole worker then shares." when it keeps state, else "Global variable $x is written while handling a request, but global variables are shared by the whole worker." in a context. Request-keyed arrays on globals aren't checked as unbounded caches. + +**Tests** (`worker-state-globals.php`, `WorkerStateRuleTest::testGlobalVariables`): in the request helper, `preg_match()` into a bound name, `sort($GLOBALS['list'])`, a nullsafe call's by-reference argument of a bound name and of a `$GLOBALS` entry (each reported once) and `sort()` of a bound object's property (reported), with read-only arguments of the same shapes and creating a first-class callable (silent); outside any context, forward, reverse, offset-reverse and chained aliases of global storage, `??=` into a bound name and a `$GLOBALS` entry (they keep the request), and a re-pointed bound name, an unset alias, a rebound alias and a reference to a bound object's property (silent); in a booted action, a `$GLOBALS` request value, a `$GLOBALS` increment and `unset()`, and a named global in a helper the action calls (reported); in another helper, a named global's offset and property `unset()`, a by-reference offset write of the request (it keeps it), compound assignments to a named global and a `$GLOBALS` entry, and destructuring into both, reported through the context (reported), a `foreach` key and value (the value keeps the request), a destructured `foreach` value, a by-reference `foreach` into a `$GLOBALS` entry (it keeps the request), a reference destructuring into a named global's offset and a `catch` binding (reported), with a guarded named function writing its own variable of a bound name, a write after a bare `unset()`, and writes after a by-reference `foreach` value or a reference destructuring item rebinds a bound name (silent); outside any context, a named global's request-valued offset and a `$GLOBALS` request value (reported); an ordinary local, destructuring a request into a bound name, a nested closure's own variable, writes after `unset()` and after a by-reference rebinding, a conditional `global`, a write before the `global` statement and a provider's boot-time scalar (silent). Two traits used by one class, in their own files (`worker-state-globals-request.php`, `worker-state-globals-account.php`), write a request and a user to a bound name at the same position, and each is reported with its own state. The same files are silent with `checkStaticStateMutation` off. + +## 3. Rules + +| # | Rule class | Identifier | Switch (default) | +|---|---|---|---| +| 3.1 | `Rules/Coroutine/RestrictedCallRule` | `hypervel.restrictedCall` | `checkRestrictedCalls` (true) | +| 3.1 | `Rules/Coroutine/RestrictedAttributeRule` | `hypervel.restrictedAttribute` | `checkRestrictedCalls` (true) | +| 3.2 | `Rules/UnsupportedCallRule` | `hypervel.unsupportedCall` | none | +| 3.3 | `Rules/Coroutine/BorrowedResourceEscapeRule` | `hypervel.borrowedResourceEscape` | `checkBorrowedResources` (true) | +| 3.4 | `Rules/ManagedTransactionRule` | `hypervel.transactionEndedEarly` | `checkManagedTransactions` (true) | +| 3.5 | `Rules/NoNativeRequestApisRule` | `hypervel.nativeRequestApi` | `noNativeRequestApis` (true) | +| 3.6 | `Rules/DiscardedDateRule` | `hypervel.discardedDate` | `checkDiscardedDates` (true) | +| 3.7 | `Rules/NoMutableDatesRule` | `hypervel.mutableDate` | `noMutableDates` (true) | +| 3.8 | `Rules/UncastEnvValueRule` | `hypervel.uncastEnvValue` | `checkConfigEnvCasts` (true) | +| 3.9 | `Rules/TypedConfigGetterRule` | `hypervel.typedConfigGetter` | `checkTypedConfigGetters` (true) | +| 3.10 | `Rules/ObserverAttributeRule` | `hypervel.observerAttribute` | `checkObserverAttributes` (true) | +| 3.11 | `Rules/MigrationColumnTypeRule` | `hypervel.migrationColumnType` | `checkMigrationColumnTypes` (false) | +| 3.12 | `Rules/BindingCycleRule` | `hypervel.bindingCycle` | `checkBindingCycles` (true) | +| 3.13 | `Rules/Coroutine/NoRawCoroutinesRule` | `hypervel.rawCoroutine` | `noRawCoroutines` (true) | +| 3.14 | `Rules/SocialitePropertyRule` | `hypervel.socialiteProperty` | `checkSocialiteProviders` (true) | +| 3.15 | `Rules/Coroutine/SwooleTableInProviderRule` | `hypervel.swooleTableInProvider` | `checkSwooleTables` (true) | +| 3.16 | `Rules/RequestDependentBindingRule` | `hypervel.requestDependentBinding` | `checkBindWhenConditions` (true) | +| 3.17 | `Rules/Coroutine/SwallowedCancellationRule` | `hypervel.cancellation.swallowed` | `checkCancellationHandling` (existing) | + +### 3.1 Restricted calls + +`RestrictedCallRule` is a `CollectedDataNode` rule over the `restricted` facts (section 2.4). + +**Reports.** A call to a method carrying `#[BootOnly]`, `#[TestsOnly]` or `#[BootOrTestsOnly]`: +- **Where:** in a request, job or operation context, as `WorkerStateAnalysis::getContext()` gives it: booted middleware and controller actions, route closures, form requests, queued handlers, the section 2.3 contexts, and their `$this` helpers. +- **Which methods:** the resolved declaration of an instance or static call (including a facade's own methods such as `swap()`); for other facade static calls, `FacadeHelper::getMethod()`. A facade whose root is unknown isn't collected. +- **Not reported:** configuration writes, exactly the calls `WorkerStateCallCollector::isConfigWrite()` recognizes (`config()` with an array, a `Config` facade write, or `set`/`push`/`prepend`/`offsetSet`/`offsetUnset` on a repository). Move that method to `WorkerStateHelper` so both collectors call it. `noRuntimeConfigMutation` owns those writes, using its own global-repository evidence, so disabling it silences them completely. Every other marked repository method, such as `Config::macro()`, stays a restricted call. + - Its evidence gains the receiver forms below: an application-container resolution (`getApplicationResolvedAbstracts()`) and a readonly promoted repository property, where `isContainerBuilt()` holds and the constructor's injected abstract is the global repository (`WorkerStateAnalysis::isPromotedConfig()`). Method and offset writes both use it. + - An offset write on a repository-typed property that `getConfigReceiver()` recognizes is a configuration write, as its `set()` is, so `WorkerStateWriteCollector` skips it and one write gets one diagnostic. Other repository properties, such as a static one or one the constructor doesn't promote, get the ordinary static and shared-write checks, like any other object property. + - `WorkerStateConfigOffsetCollector` collects `Assign`, `AssignOp` and `Unset_` nodes (its node type is `Node`, filtered by kind first), with PHPStan's scope at the write, so an offset assignment inside a return, an argument, a condition, a chained assignment or an arrow function is collected. Nested callbacks stay excluded by the context rules; an arrow-function route action is covered. +- **Message:** names the restriction and the context. The tip says to move the call to a service provider (boot), a test, or to keep per-request state in `CoroutineContext`/`scoped()`. + +**Conditions** (from PR 1's attribute arguments): +- **`whenNotDefault: 'p'`.** Find the argument bound to `p`. Omitted, or a constant equal to the declared default → silent. A type that may differ (unknown, union), or an unknown binding → reported, with "may change" wording. +- **`whenProvided: 'p'`.** An argument bound to `p` → reported; omitted → silent; an unknown binding → reported with "may change" wording. +- **`whenShared: true`.** Report only with evidence that the receiver is the worker-shared instance: + - a facade whose accessor `sharesAbstract()` is yes; + - a literal resolution from the application container whose abstract is shared, through `WorkerStateHelper::getApplicationResolvedAbstracts()`: `app('x')`, `resolve('x')`, `App::make('x')`, or the framework container's `make`/`get`/`factory` called on an application instance (`app()`, an injected application, `$this->app`) or on `Container::getInstance()`. A worker runs one application, so an `Application`-typed receiver is the booted one. Another container (`(new Container)->make('x')`) or a user override of the resolving method may hold other bindings, so it isn't evidence; + - a parameter of a container-called entry method that the container actually supplies on that invocation path, where `sharesInjectedParameter()` is yes: booted controller actions through `ResolvesRouteDependencies::transformDependency()` (excluding route-bound and defaulted parameters), and job `handle()`/`__invoke()` through `Container::call()` as `WorkerLifetimes::discoverJob()` models it. Event, listener and Bus command payloads, contextual attributes and route closure parameters aren't evidence. A parameter reassigned before the call loses the evidence, including through a by-reference argument, which PHPStan reports as a variable assignment. Under `checkRestrictedCalls`, `WorkerStateParameterCollector` also records reassignments of method parameters that may hold an object. + - a `readonly` promoted constructor property read as `$this->prop` outside closures (`WorkerStateHelper::getPromotedProperty()`; the code's class must run the constructor that promotes it), where `WorkerStateAnalysis::sharesPromotedProperty()` holds: `isContainerBuilt()` for the code's method, and `sharesInjectedParameter($constructorClass, '__construct', $prop)` is yes, so every build of that constructor injects the shared instance. + + Everything else is silent, including bounded local `new` evidence (a `new` expression receiver, or a local variable assigned only from `new`). `WorkerLifetimes::isShared($class)` is class-level and never counts as receiver evidence. Reference-alias mutations (`$alias = &$registry; $alias = new Registry;`) aren't modeled: a later call through the parameter keeps its evidence. +- **A parameter condition and `whenShared`:** both must hold. +- **Unconditional attributes:** reported regardless of receiver. + +**`RestrictedAttributeRule`** (on `InClassMethodNode`) reports a lifetime attribute that would silently disable or misstate enforcement: `whenNotDefault` or `whenProvided` naming a missing parameter or one without a default, both set, or `whenShared` on a static method. + +**Tests:** +- static, instance and facade calls, and a facade with an unknown root (silent); +- each condition's positive, negative, named-argument and explicit-default cases, plus an unpacked argument; +- contexts: request, job, operation listener and `$this` helper; +- a boot provider call, a test-case call and a first-class callable, all silent; +- `Config::set()` in a request: one `hypervel.runtimeConfigMutation` diagnostic only; with `noRuntimeConfigMutation` off (in the 2.4 switch config), `Config::set()` is silent while `Config::macro()` is still a restricted call; +- `Date::useClass()` in a request (the `Date` resolution path in section 2.1); +- receivers: a singleton-factory promoted property (reported) with contextual class and closure counterparts (silent); `app()->make()` (reported) and a local container's `make()` (silent); a parameter replaced through a by-reference argument (silent); a queued listener's promoted property (reported in a job) beside the serialized job's (silent); an inherited queued-listener handler and the helper it calls (reported), with the base's `handle()` that a serialized-job subclass runs (silent); an inherited job handler and a twice-routed action whose injections disagree, in the order where keeping one injection would report (silent); +- promoted repository property writes: `set()` and an offset write, one config diagnostic each, and a contextual repository (silent); offset writes to a static and to a non-promoted repository property, each reported once by the ordinary write checks (`WorkerStateRuleTest`); +- offset writes inside a chained assignment, an argument, an `if` condition (and a nested statement in its body) and a return, and an arrow-function route action (reported) beside a nested callback inside a route (silent) (`WorkerStateRuleTest`); +- the `Http\Client\Factory` example: `(new Factory)->globalOptions()` silent, `Http::globalOptions()` reported; +- `RestrictedAttributeRule`: each malformed case and a valid marker. + +### 3.2 Unsupported calls + +- **Reports:** any method or static call whose resolved declaration has `#[Unsupported]`, and facade static calls whose `FacadeHelper::getMethod()` has it. A per-node rule: it needs no lifetimes or contexts. +- **Message:** "`Class::method()` is not supported by this implementation and always throws.", followed by a newline and the attribute's `message` when `getArgumentTypes()['message']` has exactly one nonempty constant string (PHPStan maps positional arguments to parameter names). The guidance goes in the message, not a tip, because the GitHub, Checkstyle, JSON, GitLab and Raw formatters drop tips; PHPStan's deprecation rules also append their description on a new line. No validation of the message itself. +- **Silent:** an override without the attribute, calls through a contract or base whose resolved declaration is unmarked, and calls inside a method that is itself marked, which may reject through another (a stream's `rewind()` calls `seek()`). +- **Tests:** API-client `PendingRequest::setClient()` reported with its positional message; a bare fixture marker reported with the generic message only; the HTTP client's `setClient()` not reported; a subclass override; a marked method delegating; a facade call; a facade with its own `__callStatic()` (section 2.1). + +### 3.3 Borrowed resource escape + +A per-node rule on the lending call: it reads the callback literal (closure or arrow function) as written, since PHPStan's scope doesn't track aliases. + +**Applies** inside the callback of these methods, matched by resolved declaring class (a user override or same-named method is skipped): +- `RedisProxy::withConnection()` (and the `Redis` facade's) and `StoreContext::withConnection()`: raw handle `client()`; +- `QueuePoolProxy::withConnection()`: `getSqs()`; +- `withDriver()`/`withAdapter()`/`withClient()` declared by `FilesystemPoolProxy` or `ClientPooledFilesystem` (the `InteractsWithPooledFilesystem` users); a plain `FilesystemAdapter` lends its own driver; +- `Connection::withPinnedSession()` (and the `DB` facade's): `getPdo()`/`getReadPdo()` on the same connection, which is a variable the callback receives and never reassigns, `$this`, a `$this->property` the callback never writes, or `DB::` statics for a `DB::` pin. A property owner must be a native, non-static, non-virtual property without a get hook (`InspectsConnections::getPropertyKey()`), so every read returns the stored connection, and the callback must have `$this` (not static). Other owner forms are unknown, so silent. A `DB::connection('x')` pin isn't followed: the callback receives nothing, and `connection()`'s driver-neutral `Connection` has no PDO getters, so code taking a PDO narrows the connection into a variable first, which the variable form covers. + +Raw handles are recognized only on the lent parameter or its aliases, whose class the lending method fixes. A pinned session's PDO getters count only through the framework's declarations (`runsConnectionMethod()`, which includes `PdoConnection`), so a facade subclass's or connection subclass's own `getPdo()` is unknown. + +**Tracked values:** the lent parameter, the raw handles taken from it, local variables holding them, and arrays they were appended or written into. Branches aren't proven reachable, so an escape written in a dead branch is reported. A value read back from an array (an offset, a `foreach` value, or a destructured leaf without an exact literal item) is unknown, since an array holding a borrowed value may hold safe ones too. Nested non-spawn closures are walked too, receiving tracked variables through `use` or arrow-function capture; their returns stay inside the callback. A named function declared in the callback takes nothing from it, so its body is skipped. + +**Statement order.** Each function body is walked forward in statement order, each branch from what reaches it and joined where branches meet. A `finally` body is walked once for each way out of its `try`, and a loop's later iterations repeat until they stop changing. Branch conditions aren't evaluated, so the walk follows possible paths, not proven ones. There are no replay passes. +- **State** at each point: each variable's borrowed kind, what its arrays hold, the closures it may hold, the names bound by reference, the names bound to global or the callback's by-reference storage, and two certainties: a variable last given a literal other than null (a scalar, array, `new`, closure, `true` or `false`), and one last given an array literal holding an item that isn't unpacked. A join keeps whatever either path gives, makes a name bound by reference on either path unknown, and keeps a certainty only when both paths have it. A statement ends normally, through a `break` or `continue` of some level, or by returning, each with one joined state; exceptions take the separate path described under `try`. +- **Expressions.** Only PHP's guaranteed sequencing is followed: an assignment's value before its write, and the left side or condition of `&&`, `||`, `??`, a ternary and a `match` before the operands that depend on it, each from what reaches it. Other operands are taken in source order, an approximation where a result depends on the order of side effects in different operands. So `[$client, ($client = null)]` reads the client before clearing it, and a store is checked against its value before the write applies. Every expression PHP runs is evaluated, including a `static` initializer (each variable's own, before its binding; it only runs the first time, so its effects join the skipped path) and a computed method name (not on a nullsafe call's skipped path). Targets are assigned in PHP's order, after the value: a destructuring list item by item, a `foreach` value before its key, and `unset()` left to right. Each target evaluates its keys and offsets, then has its store checked and its write (or by-reference binding) applied before the next target, so a later throw keeps an earlier store and a later target sees an earlier write. + - `$v = expr` replaces `$v`'s facts (`$b = $a; $a = null;` keeps `$b`). An append or offset write adds to the array. Destructuring, other compound assignments, increments, `foreach` targets and catch bindings leave their targets unknown. An offset `unset()` keeps what the array holds but drops its certainties. + - `??=` on a variable certainly holding a non-null literal never runs its value. Otherwise the target holds the old or the new value, so `$client = null; $client ??= $connection->client(); return $client;` is reported and a borrowed target stays borrowed. + - A nullsafe call's arguments and invocation apply only on the path where its receiver isn't null. + - Every invocation, unlike first-class callable creation, drops both certainties: a call can change locals through references it's given or a closure it runs holds, and other coroutines can change by-reference and global variables while it waits. That covers by-reference parameters (`array_pop()`, `settype()`) and closures created before or after the literal without resolving callees. Effects of implicit calls (magic methods, destructors, an `include` sharing the scope) are an analysis boundary. +- **Branches.** `if` branches start from what their conditions leave, and a missing `else` adds the last condition's state. A `switch` enters each case with what the comparisons up to its own leave, plus fall-through; the default (wherever written) and the unmatched path get what every comparison leaves. A `switch` counts as a level for `break N` and `continue N`, and a `continue` targeting it acts like `break`. A `match` arm runs from what any of its conditions leaves, and the default from what every condition leaves. +- **Loops.** `for` initializers and a `foreach` subject run once; a `while` or `for` condition runs at the head; a `for` step runs after the body and each `continue`; a `do` body runs before its condition, so it always runs once. + - The first iteration starts from what reaches the loop. Later iterations start only from what earlier ones pass back, so the pre-loop state isn't mixed into them: a closure sharing a variable by reference, which each iteration unsets and reassigns before storing the closure, stays silent. One transfer serves every iteration. When the first iteration passes back exactly what reached it, later ones would repeat it and aren't walked. Facts only grow, a name becomes bound by reference once and certainties are only lost, so the passes end. The split costs at most one extra walk per loop entry. + - A loop leaves when its condition fails (never for a literal `true`), when a `foreach` runs out of values, and at every `break`. A `foreach` over an array literal holding an item that isn't unpacked, or over a variable certainly holding one, runs at least once; any other `foreach` may run zero times. +- **`try`/`catch`/`finally`.** Exception states are recorded only at invocations and explicit throws inside the try; other operations that can throw, such as division, aren't followed. Every catch receives the join of those states, whatever its type. An exception no catch takes escapes, unless a catch takes `Throwable` (resolved name), and a catch's own exceptions always escape. Explicit throws and invocations share this one exceptional path; only returns leave through `finally` as returns. `finally` runs separately for each way out (normal, return, each `break`/`continue` level, exception), then continues that way unless it leaves itself. An exception reaches the enclosing try's catches only after the finally, so a finally that clears a value or returns changes it first, while a return never enters a catch. `try { return $client; } finally { $client = null; }` reports the return. A returned closure's by-reference captures are read at the return too, before `finally` runs: a precision limit, since checking them afterwards would need per-path pending-return state that no realistic case has needed. +- **Exits.** `return` and `throw` end the path after the escape check. `goto` isn't followed: the path ends there. +- **Captures.** A by-value capture (an arrow function, a by-value `use`) is fixed when the closure is created, so reassigning the captured variable afterwards doesn't clear the closure. A by-reference `use` is read from the current environment wherever the closure escapes: a spawn, a return or a property store, including when the closure is carried in an array, copied to another local, or captured by value by another closure. + - A held closure records the node, the first borrowed kind it captured by value (from the fixed kinds of the callables it captured, never their live references), and the by-reference names it reads when it runs: its own and those of every callable it captured by value. So it keeps them even when the variable that held a closure is replaced; they're read at the escape, each name once, which covers closures capturing themselves. Two values holding a closure from the same node merge it: the first fixed kind and every name either reads. + - A closure created more than once (in a loop, or in a `finally` walked per way out) captures what any of those visits gives it. + - **Carried into a nested function.** A nested function's environment doesn't have the enclosing function's variables, so each held closure it captures is frozen at its creation: its live kind is read then, from the environment without the variables the enclosing function writes after the creation or in a loop around it, judged from write positions collected once per function. A variable the nested function itself captures by reference is the same binding, which it can change, so it's left out of that read too, and the held closure keeps only those by-reference names live, read from the nested function's own facts when it runs. A variable it captures by value under the same name is another binding, so it's never read for the held closure. +- **Reference bindings.** `$a =& $b` (or a side rooted at a variable, such as `$items[0]` or `$holder->x`) makes both root variable names unknown from that point on that path: their facts and certainties are cleared, they get no new ones, and a join keeps them unknown. The binding's own store is checked first, so `$this->items =& $items` is still a store. Every binding adds both sides, which covers aliases of aliases without reference groups. Both roots, and a source `$this->property`, also count as written, so owner identity, external holders and the delayed nested-function pass drop them like any other write. By-value snapshots taken earlier stay. By-reference destructuring and `foreach` values tie their target and source roots the same way. A value stored through a name bound by reference isn't followed, so `$alias = &$value; $value = $connection; return $value;` is silent; following it would need alias groups. By-reference arguments are covered only by the call clearing of certainties. +- **Detachment.** `unset()`, `static`, `global`, or binding the variable by reference (`=&`, a by-reference `foreach` value or destructuring item) points a name at another binding on the paths that run it, so a `global` or `unset()` under a condition applies only inside its branch. A held closure reading the name by reference keeps the old binding, which the walk can no longer follow by name: its value and the fixed kinds of the closures it holds join the held closure's fixed kind, and those closures' live names join its own. A detached name reached through those closures is followed the same way, each name once, so closures sharing each other's bindings end. An unset leaves the name undefined and unreferenced. `unset()` doesn't count as a write for nested functions: it doesn't change the binding one captured by reference, and a later write to the name already drops that capture's facts. +- **Generator callbacks.** A lending callback whose own nodes contain `yield` or `yield from` returns a generator before its body runs, so the borrow ends first. The first borrowed value the walk evaluates (a borrowed variable read, a closure holding one, or a raw handle taken) is reported once at the callback: "is used in a generator callback", with a tip to return the results instead of yielding them. A callback that only unsets or replaces the variable before reading it, or never uses it, is silent, as is a generator the callback creates and returns. +- A nested function that isn't reported at a spawn is analyzed after the walk with the facts at its creation (joined over every visit), without invocation tracking. Each closure passed to a spawn gets its own kind; only one with a kind is reported there and skipped afterwards. A spawned closure without one is analyzed like the others, except that it doesn't receive the `DB` facade owner: `Connection` is `NonCopyableContext`, so a child coroutine resolves its own default connection, and a same-coroutine `defer()` runs after the pin. A captured variable or `$this->property` owner keeps its evidence, since the child holds that same object. A by-reference capture that the enclosing function writes after the creation, or in a loop around it, holds whatever it was last given when the function runs, so its facts are dropped; one written only before creation keeps them. Owner and external-holder invalidation stay order-insensitive: dropping on any write is the conservative direction. + +**Reports** when a tracked value is: +- returned from the callback (including an arrow function's body), directly, as an item of an array literal, or captured by a returned closure or arrow function; +- assigned to a `$this`/static property (including array dimensions and nested properties), to a property of an object from outside the callback (a variable it receives through `use` or arrow-function capture, which a nested closure inherits only when it captures that same variable), or to the callback's by-reference `use` variable, including through a nested closure's by-reference `use` of it (arrow functions capture by value, so they can't write it). Global variables count the same way: a `$GLOBALS[...]` destination, a name bound by a `global` statement on the paths after it (one under a condition binds only inside its branch), a property of an object a bound name holds, and a nested closure's by-reference `use` of a name bound when the closure is created. `CoroutineRuleHelper::getDetachedNames()` (`unset()`, `static` or a by-reference assignment to the variable) detaches a name from both the global and the callback's by-reference destinations, a `global` statement detaches it from the by-reference one before binding it, and a nested closure keeps the bindings it captured when it was created. A reference binding detaches a name before its own store is checked, so binding a plain variable by reference only points it at another value and is never a store. A reference assignment between a plain variable and global or by-reference storage (`CoroutineRuleHelper::getStorageAlias()`, read before the left name is detached) binds the variable to that storage from then on, as `global` does; compound forms and property destinations aren't followed. A destructuring assignment stores each leaf without a key the item at its position of a keyless array literal; any other leaf's value is unknown. A nested function capturing a global or by-reference name by value holds the object the name held when the function was created, so a property written through it is a store, while assigning the name only changes its copy. Each closure object has its own `static` variables, so a callback's `static` variable doesn't outlive it. A closure or arrow function capturing a tracked value, or an array holding one, counts as the value, so storing it is reported too. A static nested closure has no `$this`; +- captured by a closure passed to a spawn that can outlive the callback. A closure counts when passed directly, in an array literal, or through a local variable or array assigned it (`$tasks[] = fn () => …; parallel($tasks)`): + - `go()`, `co()`, `Coroutine::create()`/`fork()`/`createOwned()`/`forkOwned()`/`defer()`, the Engine and Swoole `create()`/`defer()`, Swoole's `go()`/`defer()` functions, the `defer()` helpers, `Concurrent`/`WaitConcurrent` `create()`/`fork()`, and coordinator `Timer::after()`/`tick()`/`until()`; + - `parallel()`, `Parallel::add()`, and `run()`/`defer()` on the `Concurrency` facade, the `Driver` contract, `CoroutineDriver` and `ProcessDriver`, plus `SyncDriver::defer()`. When the owner is canceled, `Parallel` cancels its children synchronously but doesn't join them, so a child whose cleanup yields can still be running after the callback returns. The `Concurrency` coroutine driver uses `Parallel`, and the driver is configuration, so every facade `run()` counts; + - `wait()`/`Waiter::wait()` unless `waitForChildTermination` is a constant true expression. By default a timed-out or canceled wait stops waiting after the cleanup allowance while the child runs on. An expression with variables is unknown, since the lending call's scope doesn't know the callback's own variables. +- Spawning methods match by exact declaring class: an override, such as a `Parallel` subclass's own `add()`, runs its own body. +- An instance spawn's receiver class comes from a local assigned only `new X`, or from the lending call's scope for an expression using only `$this` and variables the callback receives unchanged. + +**Not reported:** +- local holders used inside the callback, including one a nested closure captures; +- a nested function's parameter shadowing a tracked name, an anonymous class body, and a named function declared in the callback; +- `wait()`/`Waiter::wait()` with a constant true `waitForChildTermination`, which waits for the child to terminate on every exit, including the owner's cancellation; +- results, proxies and streams. + +**Tests** (`borrowed-resources.php`; each fixture class has reported and silent cases, one per behavior): +- sources and escapes: every lender and raw handle, returns, property, global and by-reference stores, each spawning form including `wait()` with and without a constant true `waitForChildTermination`, and values carried in arrays and closures; overrides, same-named methods and subclass getters stay silent; +- pinned sessions: variable, `$this->property` and `DB::` owners, with reassigned, hooked and aliased owners silent, and spawned closures that resolve their own facade connection; +- captures: by-value captures fixed at creation, by-reference captures read at the escape, closures carried into nested functions, and self-capturing closures; +- globals and by-reference destinations: `global` and `$GLOBALS` stores, storage aliases, and detachment through `unset()`, `static` and rebinding; +- references: aliases making names unknown, including the silent store through an alias, and reference destructuring and `foreach` sources; +- control flow (`ControlFlow`): branch entries and joins, conditional `global` and `unset()`, loop exits (`break N`, `continue`, zero iterations, `do`), the first-iteration split, nonempty literals and their invalidation, `switch` and `match` entries, `try`/`catch`/`finally` ways out, the exception boundary and `Throwable` catches, `??=` and the call clearing of certainties, expression order and the less common executed positions (`static` initializers, computed method names, target offsets), target assignment order including `foreach` value before key and per-target reference binding, and generator callbacks; +- store forms (`StoreForms`): `??=`, conditional expressions, destructuring and storage aliases. + +### 3.4 Managed transaction ended early + +**Applies** inside a closure passed to the framework's `transaction()`. Both this rule and `JobDispatchedInTransactionUsesAfterCommitRule` recognize it the same way: +- a new `Concerns/InspectsConnections` trait holds: + - `getTransactionReceiverType()` (replacing the queue rule's `isTransactionCall()`), which gives the connection or `DB` facade type when its `transaction()` resolves to `ConnectionInterface`, `Connection` (the `ManagesTransactions` trait) or the `DB` facade; + - `runsConnectionMethod()`, the same declaration check for any method. The framework declarations are `ConnectionInterface`, `Connection`, `DatabaseManager` and `DB`: PHPStan's facade stub resolves `DB::connection()` through its `@mixin DatabaseManager`. An override, including a `DB` facade subclass's own static method, is unknown; + - `isFacadeConnectionCall()`, a `DB::connection()` call running the framework's `connection()`, and `getConnectionName()`, the key of a literal `DB::connection('x')` call. The queue rule's `getTransactionConnection()` uses `isFacadeConnectionCall()` too, and `BorrowedResourceEscapeRule` uses `runsConnectionMethod()` for its PDO getters; +- move the `isFacade()` it calls from `InspectsQueuedJobs` to a public static `ReflectionHelper::isFacade()`, since it's a pure name check; its callers in `InspectsQueuedJobs` and the queue rule call it there. + +**Owner identity.** A transaction-control call (`beginTransaction()`, `commit()`, `rollBack()`) belongs to the managed transaction when its receiver has the same identity as the `transaction()` call's. It counts only through the framework declarations, resolved on the transaction's receiver type for the callback's owner variables; an overriding control method makes the rest uncertain. Identity: +- the closure's first parameter, which `ManagesTransactions::transaction()` passes as the connection itself (`$callback($this)`), matches any form; +- a captured variable matches the same variable when the closure doesn't reassign it before the call; +- a `$this->property` (the same `getPropertyKey()` property as section 3.3) matches itself when the closure has `$this` and doesn't write the property before the call; +- `DB::` static calls match `DB::` static calls (both the default connection); +- `DB::connection('x')` matches the same literal name. + +Anything else is unknown, and unknown owners never match each other. A call on an unknown or different-form owner that may be the same connection (`DB::connection('mysql')->commit()` inside `DB::transaction()`) makes the rest uncertain rather than resolving configured names. A receiver whose classes are all known and none is a `ConnectionInterface` isn't a control call; PHPStan's "maybe" for a non-final class isn't enough to make it uncertain. A variable's or property's identity ends at the first top-level statement that writes it: an assignment, a compound assignment or a `foreach` target, walked through destructuring to its leaves (`InspectsConnections::getTargetLeaves()`), each a variable name or a `$this->property` key. Section 3.3's owner invalidation uses the same walk. + +**Walk.** The rule (a per-node rule on the `transaction()` call; the walk reads the callback literal) walks the closure's top-level statements in order (an arrow function's body is one returned statement), with a depth counter starting at 0: +- `beginTransaction()` adds one; `commit()`, `rollBack()` and `rollBack(null)` remove one. +- A nested function-like is skipped without clearing the facts. +- An `if`, loop, `switch` or `try` containing a control call, a control call under a conditional expression (`match`, `?:`, `&&`, `||`, `??`), an unpacked argument, or a `rollBack()` level other than a constant `null` or `0` makes the rest uncertain, so it goes silent. Branches without control calls don't. The rule doesn't model what `rollBack(N)` does, since the entry level is unknown. +- It stops after a top-level `return` or `throw`. + +**Reports:** +- `commit()`, `rollBack()` or `rollBack(null)` at depth 0; +- `rollBack(0)` (positional or `toLevel: 0`) anywhere before the walk goes silent; +- instead of the walk, a generator callback: one whose own body (not a nested function or class) contains `yield` or `yield from`. `ManagesTransactions::transaction()` calls `$callback($this)` and commits, and calling a generator only creates it, so its body runs after the commit, if at all. Reported once at the callback; the tip says to run the database work in the callback and return the results instead of yielding them. A callback returning a separate generator is an ordinary callback. + +**Tests:** balanced nested begin/commit; the closure parameter, a captured connection, a reassigned capture (silent), a capture and a parameter reassigned through destructuring (silent), a `$this->connection` owner in a closure and an arrow function (reported), a reassigned property, a property reassigned through destructuring and a get-hooked property (silent), the `DB` facade and a `DB::connection('mysql')` pair; a non-connection `commit()` before a reported one; a branch and a ternary going silent; a statement after `return` (silent); `rollBack(0)` and `rollBack(toLevel: 0)`; `rollBack(2)` (silent); another connection (silent); a nested `afterCommit()` closure (silent); an inherited `PdoConnection` (reported), and a connection overriding `transaction()`, one overriding `commit()` and a `DB` facade subclass with its own `transaction()` or `connection()` (silent); generator callbacks as a closure and an arrow function (reported), and a callback returning a separate generator and a generator passed to an overriding `transaction()` (silent). + +### 3.5 Native request APIs + +**Reports**, everywhere in analyzed code: +- calls to `header`, `header_remove`, `headers_sent`, `headers_list`, `http_response_code`, `setcookie`, `setrawcookie`, `session_*` except `session_create_id` (it only generates an ID; the session handlers use it), `connection_aborted`, `connection_status` and `ignore_user_abort`; +- calls to `getallheaders` and `apache_request_headers`; +- `filter_input`, `filter_input_array` and `filter_has_var` when their input type (`type`, or `input_type` for `filter_has_var`, bound by name or position) is `INPUT_GET`, `INPUT_POST` or `INPUT_COOKIE`. They read the original SAPI input. A selector that isn't constant, or is unpacked, is skipped, and `INPUT_SERVER`/`INPUT_ENV` read the process like `$_SERVER`/`$_ENV`; +- reads or writes of `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_REQUEST`, `$_SESSION`. + +Native function resolution applies throughout: an application function of the same name is silent. + +**Message:** these PHP APIs don't operate on Hypervel's request, response or session state. The tip gives the replacement: +- the `Request` methods, including `header()` for the header functions; for the filter functions, reading the value from the `Request` and validating or filtering that; +- response `header()`/`cookie()`/`setStatusCode()`; +- the `Session` store; +- streamed-response disconnect handling: a generator callback or `IterableStreamedResponse` stops at the first failed write after the client disconnects, and `cancelOnDisconnect()` (opt-in) also stops it during a wait when the connection closes; +- for `$_SESSION`, that it's shared by the worker process. + +`$_SERVER`/`$_ENV` aren't covered. Intentional interoperability takes an ignore with a reason. + +**Framework hits** (checked): +- `LoadEnvironmentVariables::writeErrorAndDie()` calls `http_response_code(500)`, an FPM leftover from Laravel. It does nothing in Hypervel, which bootstraps from the CLI before any response exists, so remove the line. +- Horizon `WorkCommand::handle()` calls `ignore_user_abort(true)` for `fast_termination`, as upstream does. In the CLI it stops the worker dying on its next output after its terminal goes away (it doesn't affect signals), so it takes an ignore with that reason. + +**Porting guide:** the "Streamed Responses" section becomes "Native Request Functions" (`#native-request-functions`), one concise paragraph covering the native header, cookie, status, session, superglobal and connection functions. It keeps the streaming replacement sentence and link, and links `static-analysis#native-request-apis`, which section 4 creates. + +### 3.6 Discarded dates + +A per-node rule on `Stmt\Expression`. It **reports** a statement-level method call (result unused, including nullsafe calls and the last call of a chain) on a date receiver, when all of these hold: +- for every class the receiver may be, Carbon declares the method natively (declaring class `CarbonInterface`, `CarbonImmutable`, `Carbon` or a Hypervel subclass; their traits count) or by an `@method` tag on one of those classes. A native override is judged by its own declaring class. Carbon's `__call()` runs its tagged methods before any macro, so a tagged name is never a macro, and a name only a macro or a user's tag declares is skipped; +- its name is on the verified date-value modifier list (matched case-insensitively, as PHP calls native methods): + - prefixes `add*`, `sub*`, `startOf*`, `endOf*`, `next*`, `previous*`, `nth*`, `firstOf*`, `lastOf*`, `round*`, `floor*`, `ceil*`; + - exact names `modify`, `change`, `set`, `setUnit`, `setUnitNoOverflow`, `setDate`, `setTime`, `setDateTime`, `setTimestamp`, `timestamp`, `setTimezone`, `timezone`, `setISODate`, `setDateFrom`, `setTimeFrom`, `setDateTimeFrom`, `setTimeFromTimeString`, `setDaysFromStartOfWeek`, `setAnchorDay`, `utc`, `shiftTimezone`, `midDay`, `average`, `plus`, `minus` (Carbon's tags and `DateHelpers`), `rawAdd`, `rawSub`; + - the unit setters of Carbon's `$units` (`year`, `month`, `day`, `hour`, `minute`, `second`, `milli`, `millisecond`, `micro`, `microsecond`), each in its plain, plural, `set` and `set`-plural form (e.g. `years`, `setYear`, `setYears`). They always set: `setUnit()` has no getter form. + +**Getter/setters** act as getters when their first argument is omitted or null: `dayOfYear`, `weekday`, `isoWeekday`, `week`, `isoWeek`, `weekYear`, `isoWeekYear`, `tz`, `utcOffset`, and Carbon's 77 tagged unit-of-unit methods (`dayOfWeek`, `weekOfYear`, …), matched by `/^[a-z]+Of[A-Z][a-z]+$/` after the modifier prefixes, case-sensitively as Carbon dispatches them. Reported only when the value argument is supplied and isn't definitely null; a possibly-null or unpacked value gets the may-message. The value binds by the native parameter name when the method is native: PHPStan merges the `Week` trait's `@method static dayOfYear(int $dayOfYear)` tag into the native `dayOfYear(?int $value)`, but a named argument binds to `$value`. + +Option setters (`locale`, `settings`, `setLocalTranslator`) never match, because they mutate and return `$this`. No Carbon method is marked pure. + +**Receivers:** every class is a `DateTimeImmutable` → definite; every class is a `DateTime` (mutable `Carbon`) → silent; otherwise (`CarbonInterface`, a union) → may. + +**Messages** (tip: "Immutable date methods return a new date, so assign the result where it's needed."): +- definite: "The result of addDay() is discarded, so the immutable date is unchanged." +- may: "The result of addDay() is discarded, so any change it makes to an immutable date is lost." + +Native `DateTimeImmutable` methods are skipped, since PHPStan reports those. + +**Tests:** each family on `CarbonImmutable`, plus the Hypervel subclass's tags and `plus()`, a nullsafe call and a chain; `CarbonInterface` and a mutable/immutable union (may); the getter/setter cases (positional, named, possibly null, unpacked, omitted, null, a tagged unit-of-unit method); option setters, a used result and first-class callables (silent); mutable `Carbon` and native `DateTimeImmutable::modify()` (silent); a native override and a macro documented by a user `@method` tag (silent). + +### 3.7 Mutable date creation + +A per-node rule on `CallLike`. **Reports:** +- `new Carbon\Carbon`/`new Hypervel\Support\Carbon`; +- a static call on exactly those classes, named (not `self`/`static`/`parent`, not a subclass), whose resolved result apart from null and false is a mutable `Carbon` (`now()`, `parse()`, `create*()`, `instance()`, `make()`, …). A void method such as `setTestNow()` leaves no class and is silent, as is `getTestNow()` (`Closure|CarbonInterface|null`). + +**Message:** "Hypervel\Support\Carbon::now() creates a mutable date." (or "new Carbon\Carbon …"). **Tip:** "Create dates with the Date facade, the now() and today() helpers, or CarbonImmutable." (AGENTS "Use immutable dates by default"). Intentional mutable interop or conversion takes an ignore. + +**Framework hits** (checked): +- `HasAttributes::serializeDate()`'s `Carbon::instance()` takes an inline ignore. The branch isn't dead: `instance()` clones a date that's already a `Hypervel\Support\Carbon`, so a mutable subclass's `toJSON()` applies, which `CarbonImmutable::instance()` would lose. +- The `types/Database/Eloquent/Casts/Attribute.php` fixture uses `new CarbonImmutable` for its narrower getter. + +**Tests:** `new` and static calls on both classes, a finder returning null (`make()`, `createFromFormat()`); `hasTestNow()`, `getTestNow()` and `setTestNow()`, a subclass and its `static::` call, `CarbonImmutable::now()`, `Date::now()`, `now()` and a first-class callable (silent). `IntegrationTest`'s expectations gain the reports for the ported fixtures' deliberate `Carbon::now()` calls (`bug-1744`, `model-properties`). + +### 3.8 Uncast env values + +A rule on PHPStan's `LiteralArrayNode`, using each item's scope. **Reports**, in config files, an `env()`/`Env::get()` read (including an `Env` subclass that inherits `get()`; one overriding it is unknown) whose default is definitely a bool, int or float and whose value reaches an array item's value unchanged: as the item value itself, through an assignment's value, `?:`, `??` and ternary branches (not the condition), or as another read's default (`env('A', env('B', 60))` reports the inner read). A read in a condition or cast is converted, so `(int)` and the `($x = env(...)) === null ? null : (int) $x` form are silent. Values passed through other calls (`filter_var()`) or held in variables aren't followed. Checking the item's resulting type instead would misreport `env('REDIS_TLS', false) ? 'tls' : 'tcp'`, whose string result is intended. The config-file check runs only once a read is found. + +**Config files:** `NoEnvCallsOutsideOfConfigRule`'s detection, including its editor-mode file mapping, moves to `ConfigSourceResolver::isConfigFile()` (beside `isPackageConfigFile()`), which both rules call. The resolver takes the autowired `singleReflectionFile`/`singleReflectionInsteadOfFile` parameters; the env rule's test builds a resolver with them. + +**Message:** "env('DB_PORT') can return a string, but its default is an int." **Tip:** "Cast it in the config file to its default's type, such as (int) env('DB_PORT', 3306). When the variable holds a name or other text, use a string default instead." + +**Framework hits** (checked): +- `logging.php` `syslog.facility` (framework config and both skeletons): Monolog maps only facility names from strings and rejects numeric strings, so the variable always holds a name. The default becomes `'user'`, which Monolog maps to `LOG_USER`; `LogManager`'s `?? LOG_USER` fallback stays. It's a runtime change, so it gets its own commit. Skeleton configs carry no ignores (the sync tool strips `class.notFound` ones), since an app without this check would report an unmatched ignore. +- `src/sentry/config/sentry.php` `spotlight` (true, false or a URL, read at `SentryConfig::configHasSpotlightEnabled()`) takes an inline ignore with that reason. + +**Tests:** in a config directory: `env()` and `Env::get()` with int, bool and float defaults, `?:`, `??` and ternary branches, an assignment, a list item, a nested default, a typed non-constant default and Testbench's inheriting `Env` (reported); a cast, `filter_var()`, a ternary condition, the null-check form, a variable, a string default, no default and an `Env` subclass overriding `get()` (silent). The same read outside a config directory is silent. `NoEnvCallsOutsideOfConfigRule` applies the same exact-declaration check to its `Env` readers, with the overriding subclass in its readers fixture. + +### 3.9 Typed config getters + +Application mode only (`! RuntimeInputs::isPackageApplication()`, after `RuntimeInputs::track()`): an application using a package can give the keys it reads other values. The components analysis and the analyzer tests boot a package application, so the rule is silent there; the skeleton analysis runs it. + +**Reports** `config('key')` (the resolved `config` function), or `Config::get('key')`/a repository `get('key')` as `ConfigHelper::getMethodName()` recognizes them, with a constant key (a union of constant keys counts) where `ConfigParser::getValue()` says every key is definitely present and the union of their values is definitely one of `string`, `int`, `float`, `bool`, `array`. An absent key's default doesn't count. Unpacked arguments are skipped. + +**Application repository only.** The application's config values say nothing about another repository, which may hold other values. So `ConfigHelper::getMethodName()` recognizes a repository method only on: +- `config()` without arguments; +- a literal application-container resolution whose abstracts all alias to `'config'` (`WorkerStateHelper::getApplicationResolvedAbstracts()`, after `RuntimeInputs::track()`); +- the `Config` facade. + +A repository received as a parameter or property, or built with `new`, gets its declared return types, and the config rules skip it. This covers the return-type extension, `ConfigTypeRule`, `ConfigCollectionRule` and this rule. Typed getters keep their declared types, and the existing override checks are unchanged. The type tests cover a parameter and a `new Repository` with values that differ from the application's config. + +**Message:** "Config key 'app.name' always holds a string, so read it with string()." **Tip:** "Use config()->string(), Config::string() or the repository's string(), which return the value's type and throw if the configuration changes it." There's no check for unneeded defaults. + +Keys are described with `VerbosityLevel::precise()`: `value()` truncates long keys. `ConfigTypeRule` and `ConfigCollectionRule` switch to it too. + +**Tests:** a `tests/PHPStan/application/config/typed.php` fixture config. In application mode (the test flips `RuntimeInputs::$packageApplication` and restores it): each type, spread across the helper, the facade and a repository, a present key with a default, a union of keys (reported); a union value, null, an absent key with a default, typed getters and a write (silent). The same file in package mode is silent. `ResultCacheTestCase` turns the switch off, since its fixtures read config through `get()` to exercise its types. + +### 3.10 `#[ObservedBy]` convention + +A rule on `InClassMethodNode`. **Reports** an unconditional top-level `X::observe(...)` statement (directly in the method body, not under a condition or closure) in a service provider's `register()`/`boot()`, where: +- `X` is a named `Model` subclass whose native `observe()` is `Model`'s own (an override is skipped); +- the argument is a constant class-string or constant list of them (`Observer::class`, an array of them, a class constant); objects and runtime values are dynamic and silent; +- the application declares the model: a model whose real file path is under an installed Composer package's real directory (`InstalledVersions::getAllRawData()`, excluding the root package) is a dependency, where the application can't add an attribute. Real directories cover path repositories, whose source can be outside the vendor directory. The decision tracks `RuntimeInputs::track()`, which covers Composer's installed metadata. PHPStan's `analysedPaths` isn't used: it narrows to the edited file in editor mode, so results would differ between editor and CLI runs; Composer's metadata doesn't depend on the analysed file. + +**Message:** "App\Models\Invoice registers its observers with observe() in a service provider." **Tip:** "By convention, list them in #[ObservedBy] on the model. Subclasses inherit attribute observers, so keep observe() with an ignore when they are meant for this class only." (AGENTS "Use Laravel observer conventions"; `resolveObserveAttributes()` merges ancestors' attributes.) + +**Tests:** a class-string, a literal list and a class-constant list in `register()` and `boot()` (reported); an observer object, a method's result, a model overriding `observe()`, dependency models (a fixture Composer vendor directory whose `installed.php` lists a vendor package and a linked package outside it), a conditional, a closure and a non-provider class (silent). + +### 3.11 Migration column types (default off) + +A per-node rule on `MethodCall`. **Reports** these `Blueprint` methods, matched by resolved declaring class (a subclass override is skipped). `Blueprint` is the schema builder, so no migration-path check is needed: +- `enum()`: "enum() creates a database enum, so adding a value needs a migration." Tip: "Use a string column with a PHP enum cast." +- `set()`: "set() creates a database set, so adding a value needs a migration." Tip: "Store the values in a jsonb() column cast with AsEnumCollection, or in a related table." +- `json()`: "json() creates a json column, which PostgreSQL can't index or compare like jsonb." Tip: "Use jsonb() for JSON the database queries, indexes or validates, or text() for a value only read back whole. jsonb() uses the appropriate type for each database." (AGENTS "Use semantic column types".) The docs section says the same. + +There's no `*Tz` check. Off by default because existing migrations shouldn't be edited; the neon comment says so. + +**Tests** (a neon turning the switch on): each method on a closure's `Blueprint` (reported); `string()`, `jsonb()`, `text()` and a subclass's `json()` override (silent). + +### 3.12 Binding cycles + +A per-node rule. **Reports** `bind`/`singleton`/`scoped` on the application container, with exactly one constant string each for `abstract` and `concrete` (bound by name or position), which differ, where the booted container's `getAlias(concrete)` is the abstract (after `RuntimeInputs::track()`). `Container::getClosure()` builds directly only when abstract === concrete, and `resolve()` applies `getAlias()` first, so the binding resolves itself. + +- **Application container:** the framework container's method on an application instance or `Container::getInstance()`, through `WorkerStateHelper::isApplicationContainerCall()` (extracted from `getApplicationResolvedAbstracts()`), or the `App` facade when the method's declaring class is exactly `App`. A facade subclass's own method and `(new Container)` are silent. +- `bindIf`/`singletonIf`/`scopedIf` aren't reported: they skip an abstract that's already bound, and the booted state can't show whether the call installed the binding or was skipped. + +**Message:** "singleton() binds 'auth' to Hypervel\Auth\AuthManager, which is an alias of 'auth', so resolving it never ends." **Tip:** "Bind a closure that creates AuthManager with new instead of its class name." (AGENTS binding pattern 1). + +**Tests:** `$this->app->singleton()`, `app()->bind()`, `App::scoped()`, `Container::getInstance()->singleton()` and an `Application` parameter's `bind()` (reported); `singletonIf()`, a closure, the same class, a concrete that isn't an alias, `(new Container)` and a facade subclass overriding `singleton()` (silent). + +### 3.13 Raw coroutines + +An exception escaping a raw coroutine, including a cancellation, is a fatal error that ends the worker process (probed). + +**Reports**, matched by exact declaring class (an override is silent, an inheriting subclass is reported): +- `Swoole\Coroutine::create()`; +- `Swoole\Coroutine\go()`, by resolved function name, so an aliased import counts; +- `Hypervel\Engine\Coroutine::create()` and `execute()`, the instance form of the same spawn. + +Swoole short names are already rejected by the application's bootstrap. `Swoole\Coroutine\run()` isn't reported: `Hypervel\Coroutine\run()` only adds hook-flag setup around it, which isn't the safety this rule is about. + +**Tip:** use `Hypervel\Coroutine\Coroutine::create()`, `go()` or `co()`; `Coroutine::fork()` and the helpers' `copyContext` argument copy the parent's context. A direct call stays, with an ignore, only when its callback catches every exception itself (AGENTS "Contain cancellation in direct engine coroutines"). + +**Framework hits** (checked), each with an inline ignore: +- `Engine\Coroutine::create()`/`execute()` and `Coroutine\Coroutine::createWithContext()`: the primitive and the wrapper; +- `Watcher\Watcher` and `Grpc\Client\Connection::startReceiver()`: their callbacks contain cancellation and failures. The gRPC receiver is the terminal owner of its coroutine: cancellation ends it, and any other escape, including a native close failure carrying cancellation (`preferCancellation()`), is reported through `FailureReporter::report()`, since an uncaught exception in a raw coroutine stops the process. `terminateAfterCancellation()` reports a termination failure the same way and rethrows the caller's cancellation; +- `Engine\SafeSocket::loop()`: the engine package sits below `hypervel/coroutine`, and its send loop contains every failure, including its logger's, which it reports through `FailureReporter::report()` before closing the socket; +- `RunTestsInCoroutine`'s deadline watchdog: it runs no application code, catches its own timeout, and nothing cancels it. + +**Tests:** each reported form, including an inheriting subclass; the framework helpers, `Swoole\Coroutine\run()`, an override and a first-class callable (silent). + +### 3.14 Socialite properties + +A per-node rule on `PropertyFetch`. It reports any `$this->X` access, read or write, in a subclass of the framework class whose getter reads X through `getContext('X', $this->X)`, covering redeclared defaults (e.g. `FacebookProvider::$scopes`). Providers are cached for the worker (`new` in `SocialiteManager`, so the worker-state checks don't see them): a read misses the request's value, and a write changes the default for every request, as Laravel's own setters (`$this->parameters = $parameters`) would if ported. + +| Properties | Framework class | Getter / setter | +|---|---|---| +| `parameters`, `stateless`, `additionalConfig` | `Socialite\AbstractProvider` | `getParameters()`/`with()`, `isStateless()`/`stateless()`, `getConfig()`/`setConfig()` | +| `clientId`, `clientSecret`, `redirectUrl`, `scopes`, `usesPKCE` | `Socialite\Two\AbstractProvider` | `getClientId()`/`setConfig()`, `getClientSecret()`/`setConfig()`, `getRedirectUrl()`/`redirectUrl()`, `getScopes()` (also merges configured scopes)/`setScopes()`, `usesPKCE()`/`enablePKCE()` | +| `fields`, `popup`, `reRequest`, `version` | `Socialite\Two\FacebookProvider` | `getFields()`/`fields()`, `getPopup()`/`asPopup()`, `getReRequest()`/`reRequest()`, `getGraphVersion()`/`usingGraphVersion()` | +| `scopeKey` | `Socialite\Two\SlackProvider` | `getScopeKey()`/`setScopeKey()` | + +**Not reported:** property declarations; the framework class itself, which owns the property (`withConfig()` writes `additionalConfig`); an override of that property's getter, which reads the default by design; a constructor, which runs once when the provider is built, so it can only set or read the default; and a same-named property on an unrelated provider. PHPStan records a class's parents as dependencies of its file, so the ancestry needs no tracking. There are no framework hits today. + +**Message:** "$this->scopes holds the provider's default for every request, not the current request's value." **Tip:** "Read it with getScopes() and set it with setScopes(), which use the provider's coroutine context." + +**Tests:** a read, an inherited base property, a write and a `FacebookProvider` property (reported); a redeclared default, a constructor write, a getter override, the non-context `scopeSeparator` and a same-named property on another provider (silent). + +### 3.15 Swoole tables in providers + +A per-node rule on `New_`. **Reports** `new` of `Swoole\Table` or a subclass (it isn't final) executed in a `ServiceProvider`'s `register()` or `boot()` body, including under conditions, but not inside closures or arrow functions (singleton factories, listeners), which run later. There are no framework or monorepo hits: Reverb's tables are built in a lazy singleton factory. + +**Message:** "A Swoole table created in a service provider's register() is allocated by every process that boots the application." **Tip:** "Create it from a BeforeServerStart listener, so only the server allocates it before starting its workers." (AGENTS). + +**Tests:** `register()` and a subclass under a condition in `boot()` (reported); a closure and an arrow-function factory, and a non-provider `register()` (silent). + +### 3.16 Request-dependent `#[BindWhen]` + +A rule on `InClassNode`, over the class's exact `#[BindWhen]` attributes (the container matches the name exactly). The condition (`condition`, or the second positional argument) is read as written and never executed: PHPStan analyzes attribute closures, but nothing there tells a rule it's in a condition. Names resolve through the class scope, since PHP 8.5 constant-expression closures can't capture variables. A condition closure is walked whole; PHP allows only static closures and first-class callables there, not arrow functions. Nested closures and arrow functions are walked too, since predicates often run callbacks (`array_any(..., fn ($pattern) => request()->is($pattern))`). The rule finds reads in the condition's code without proving a callback runs, and its message says the condition "contains" a read. Named functions and class bodies, including anonymous classes, are declarations and are skipped. A first-class callable naming a static method that is itself one of the reads below (`Auth::check(...)`, `RequestContext::has(...)`) is reported directly. Otherwise a first-class callable naming a function or static method (`Conditions::onApi(...)`, or `self::`/`parent::` resolved from the class scope; `static::` is late-bound, so unknown) is walked through its parsed declaration (`ConstructorParser::findMethod()`, which `findConstructor()` now uses), and its file is tracked. Its names resolve in a scope for the declaration's own file and namespace (`ScopeFactory`, `enterNamespace()`), since an unqualified function name falls back from that namespace, and for a method, inside its declaring class (`enterClass()`), so `self::` constants resolve. Each condition tracks `RuntimeInputs::track()`, since the booted aliases decide which abstracts resolve the request. + +**Reports** the first read of request or user state. A facade or `RequestContext` subclass counts, but its own declaration of the method is an override, so it's unknown. A facade subclass forwards to the root only when its `__callStatic()` is `Facade`'s, `Hash`'s or `Crypt`'s (`FacadeHelper::forwardsToRoot()`, shared with `getMethod()`), so one with its own `__callStatic()` is unknown for `Request` and `Auth` reads and guard sources: +- `request()`; +- a `Request` facade call that the current request runs: not the facade's own methods (`swap()`, …) or the request's static methods (`hasMacro()`, `create()`, …); +- `RequestContext::get()`, `getOrNull()`, `has()`, `getValue()` and `getOrSetValue()`; +- `user()`/`check()`/`guest()`/`id()`/`hasUser()` on the `Auth` facade, or on `auth()` or a guard chained from it or `Auth::guard()`; +- resolving an abstract that aliases to `request` from the application container (`WorkerStateHelper::getApplicationResolvedAbstracts()`), or from the condition's first parameter, which receives the resolving container (new `getContainerResolvedAbstracts()`, sharing the argument reading). The parameter's evidence follows PHP's scoping: + - a nested arrow function keeps it unless one of its own parameters has the same name; + - a nested closure keeps it only through `use`, and not when one of its parameters has that name; + - a body that writes the variable (`CoroutineRuleHelper::assigns()`, section 2.5) loses it, regardless of order. Each body is scanned on its own, so a nested closure's own variable of that name doesn't affect the outer body. + +Configuration reads such as `Auth::getDefaultDriver()` are fine. A guard held in a variable isn't followed. The `Context` facade and the session aren't covered. + +**Message** (at the read, or at the attribute for a first-class callable): "The #[BindWhen] condition on App\Payments contains a read of the current request or user, but a matched condition binds for the worker's lifetime." The docs say nested callbacks are inspected. **Tip:** "Conditions must depend only on state set while the application boots. Register a scoped() binding to choose an implementation per request." + +**Tests:** each read form, including a named `condition` argument, the container parameter resolving `Request::class`, a static-method and a function first-class callable, `self::` and `parent::` callables, `RequestContext::has(...)` and `Auth::check(...)` named directly, and a static method resolving `self::REQUEST` (reported); an overriding `Auth` subclass's `check(...)` named directly, and an `Auth` subclass with its own `__callStatic()` (silent); `Auth::getDefaultDriver()`, resolving `config`, a boot-stable static method, `Request::hasMacro()`, overriding `RequestContext` and `Auth` subclasses (including `guard()`), and a reassigned container parameter, including through destructuring (silent). Nested callbacks: an `array_any()` callback reading `request()`, and a `use ($app)` closure resolving `request` after an earlier nested closure assigns its own `$app` (reported); an arrow function whose own `$app` parameter shadows the container, and a closure that doesn't capture `$app` (silent). A second fixture file in another namespace declares its own `request()`: its attribute naming the first namespace's `onAdminPath(...)` is reported, and the first file's attribute naming its `onLocalFlag(...)` is silent. The fixtures use PHP 8.5 static attribute closures, which PHP 8.4 can't compile, so `composer.json` excludes both files from the dev classmap by name (Composer's `*` needs at least one character): they're only parsed, never loaded. + +### 3.17 Swallowed cancellation + +`SwallowedCancellationRule` is a `CollectedDataNode` rule over `SwallowedCancellationCollector`'s facts (section 2.4). + +**Applies** in request, job and operation contexts and their `$this` helpers, as `WorkerStateAnalysis::getContext()` gives them. + +**Reports** a `try` whose block (outside nested closures) directly contains one of the operations below, when the first catch that receives `CanceledException` (a type it `is()`: that class, `Exception` or `Throwable`) doesn't propagate it. Later catches never receive it. The message names the first operation found. + +- **Nested tries.** An operation in the block of a nested `try` whose receiving catch doesn't propagate it doesn't count here; that `try` is reported itself. One whose catch propagates counts, as do the nested catch and `finally` bodies. +- **Receivers.** The collector has the scope at the `try`, so a method call's receiver, and a static call's class expression, is typed by `CoroutineRuleHelper::getReceiverType()`, now public and shared with `ExactCancellationCatchRule`. A local the block may change is one it mentions other than inside the expression being typed or as a method or static call's receiver: + - a chain rooted at a local variable that the block only calls methods on keeps the variable's type at the `try`; + - one assignment of the variable, as a top-level statement before every call on it, types the chain with the assigned expression in the variable's place (`$query = Import::query(); $query->where(...)->get()`, `$model = Import::class; $model::create(...)`). A `??=` assignment counts the same way, typed as the assigned expression when the variable is undefined at the `try`, as the variable's own type when it's definitely defined and never null (the assigned expression never runs, so it isn't read), and otherwise as the union of its non-null type there and the assigned expression; + - a call argument reading a local the block may change is typed as `TypeExpr(mixed)`, so a call that selects its result's class by it gives no class (`$class = X::class; $app->make($class)->pop()`, `app($class)`), while a call whose type doesn't depend on it keeps its class (`$query->where('tenant', $tenant)`, `Import::whereIn('id', $ids)`). PHPStan's own inference, including return-type extensions, decides this, not a list of factories; + - any other read of such a local leaves the type unknown, such as an assigned expression reading one (`$held = new X; $alias = $held;`), as does any other write of the root variable. An assigned expression or type-dependent argument reading a `$this` property the block writes directly (`getWriteTargets()`) is unknown too, without following what the property was given; + - the chain is rebuilt as new nodes, since a clone keeps PHPStan's cached expression key; + - a `$this->property` the block writes directly (`getWriteTargets()`, the targets `assigns()` walks) follows the same rules as a local when it's a native, non-static, non-virtual property without a `get` or `set` hook, so `$this->channel = $replacement; $this->channel->pop()` is typed by `$replacement`. A hook could return or store another object, so a hooked or virtual property that the block writes is unknown. A `??=` on a property is typed as the union, since a typed property may be uninitialized; + - without a direct write, a property, `$this` and other roots use the scope's type: calls that change a property aren't followed; + - null is left out of the receiver's type, since a call on null fails or, through `?->`, doesn't run. PHPStan gives no class reflections for a union with null, so a nullable receiver would otherwise never count. + + A direct construction is just one such assignment. The exact-catch rule gains the same typing, so a channel assigned from a typed variable counts as a native wait. + +**Operations.** Only these exact forms count, matched by resolved declaration with overrides respected. A model's or builder's forwarded method counts as the method it forwards to, read through the reflection's prototype, since PHPStan wraps extension methods. The receiver's class reflections come from its type, so they keep the type arguments a builder's model macros depend on. A trait method is declared by the class using it, so a method of a framework trait on an application model (`SoftDeletes`, `Prunable`) is matched by its declaring trait (`PhpMethodReflection::getDeclaringTrait()`) when the declaring class has no entry and isn't a Redis class (`RedisProxy` takes `pipeline()` and `transaction()` from its `MultiExec` trait); a model's own override has no declaring trait. Each runs its work during the call, can reach a cancellation point there, and lets the cancellation reach the caller (fast paths that skip I/O, fakes and pretend mode don't make swallowing safe): +- `Connection::runQueryCallback()` rethrows when `causedByCancellation()`; +- `RedisProxy` and `PendingRequest` (`:1191`, `:1358`) propagate it; +- `WaitGroup::wait()`, `Waiter::wait()` and `Parallel::wait()` throw `CanceledException` themselves; +- under Hypervel's `throwException: true` cancellation, Swoole channel `push`/`pop`, Swoole `WaitGroup::wait()`, `Coroutine::join()`, `Coroutine::sleep()` and hooked `sleep`/`usleep` throw `CanceledException` (probed); +- a discarded `Sleep` sleeps in its destructor at the end of the statement, and `then()` sleeps during the call, so a cancellation reaches the `try` (probed). +- **Database:** + - `ConnectionInterface`, `Connection`, `PdoConnection`, `DatabaseManager` and the `DB` facade: `select`, `selectOne`, `selectFromWriteConnection`, `selectResultSets`, `scalar`, `insert`, `update`, `delete`, `statement`, `affectingStatement`, `unprepared`, `threadCount`, `transaction`, `beginTransaction`, `commit`, `rollBack`; + - query `Builder` (with its `BuildsQueries` and `ExplainsQueries` methods), directly or through Eloquent forwarding: `get`, `first`, `firstOrFail`, `sole`, `find`, `findOr`, `value`, `rawValue`, `soleValue`, `pluck`, `pluckWithColumn`, `implode`, `count`, `min`, `max`, `sum`, `avg`, `average`, `aggregate`, `numericAggregate`, `exists`, `existsOr`, `doesntExist`, `doesntExistOr`, `getCountForPagination`, `explain`, `insert`, `insertGetId`, `insertOrIgnore`, `insertOrIgnoreReturning`, `insertUsing`, `insertOrIgnoreUsing`, `update`, `updateFrom`, `updateOrInsert`, `upsert`, `delete`, `truncate`, `increment`, `incrementEach`, `decrement`, `decrementEach`, `paginate`, `simplePaginate`, `cursorPaginate`, `chunk`, `chunkMap`, `chunkById`, `chunkByIdDesc`, `orderedChunkById`, `each`, `eachById`; + - Eloquent `Builder`: those it declares itself, plus `firstWhere`, `firstOr`, `findSole`, `findOrFail`, `findOrNew`, `findMany`, `valueOrFail`, `getModels`, `fromQuery`, `fillAndInsert`, `fillAndInsertOrIgnore`, `fillAndInsertGetId`, `create`, `createQuietly`, `forceCreate`, `forceCreateQuietly`, `firstOrCreate`, `firstOrNew`, `updateOrCreate`, `createOrFirst`, `incrementOrCreate`, `touch`, `forceDelete`. Not `fillForInsert()`, which only prepares models; + - `Model`, including static calls and the protected increments its `__call()` runs: `all`, `destroy`, `forceDestroy`, `save`, `saveQuietly`, `saveOrFail`, `saveOrIgnore`, `update`, `updateQuietly`, `updateOrFail`, `push`, `pushQuietly`, `delete`, `deleteQuietly`, `deleteOrFail`, `forceDelete`, `refresh`, `refreshForUpdate`, `fresh`, `touch`, `touchQuietly`, `increment`, `decrement`, `incrementEach`, `decrementEach` and their `Quietly` forms, `load`, `loadMissing`, `loadAggregate`, `loadCount`, `loadMax`, `loadMin`, `loadSum`, `loadAvg`, `loadExists`, `loadMorph` and its `Aggregate`/`Count`/`Max`/`Min`/`Sum`/`Avg` forms; + - `SoftDeletes`: `restore`, `restoreQuietly`, `forceDelete`, `forceDeleteQuietly`, `forceDestroy`; its scope's builder macros `restore`, `restoreOrCreate`, `createOrRestore`, which `BuilderMacroResolver` resolves to `SoftDeletingMacros`; `Prunable`: `prune`, `pruneAll`; `MassPrunable`: `pruneAll`; + - Eloquent `Collection`: `load`, `loadMissing`, `loadMissingRelationshipChain`, `loadAggregate`, `loadCount`, `loadMax`, `loadMin`, `loadSum`, `loadAvg`, `loadExists`, `loadMorph`, `loadMorphCount`, `fresh`. Not its in-memory `find()`/`findOrFail()` or `toQuery()`; + - relations, by declaring class (calls a relation forwards to its builder match the builder's): + - `Relation`: `get`, `getEager`, `sole`, `touch`, `rawUpdate`; `getResults()` is abstract, so each concrete declaration counts: `HasOne`, `HasMany`, `MorphOne`, `MorphMany`, `BelongsTo`, `HasOneThrough`, `HasManyThrough`, and `BelongsToMany` below; + - `HasOneOrMany`: `findOrNew`, `firstOrNew`, `firstOrCreate`, `createOrFirst`, `restoreOrCreate`, `createOrRestore`, `updateOrCreate`, `incrementOrCreate`, `upsert`, `save`, `saveMany`, `create`, `createMany`, `forceCreate`, `forceCreateMany` and their `Quietly` forms; `MorphOneOrMany`: `forceCreate`, `upsert`; `MorphMany`: `forceCreate`; `BelongsTo` and `MorphTo`: `touch`; `MorphTo`: `getEager`; + - `BelongsToMany`: the same find-or-create and save methods (without `upsert` and `forceCreateMany`), `getResults`, `get`, `find`, `findSole`, `findMany`, `findOrFail`, `findOr`, `firstWhere`, `first`, `firstOrFail`, `firstOr`, `paginate`, `simplePaginate`, `cursorPaginate`, `chunk`, `chunkMap`, `chunkById`, `chunkByIdDesc`, `orderedChunkById`, `each`, `eachById`, `touch`, `allRelatedIds`, and its `InteractsWithPivotTable` writes, which PHPStan declares on `BelongsToMany`: `attach`, `detach`, `sync`, `syncWithoutDetaching`, `syncWithPivotValues`, `toggle`, `updateExistingPivot` and their `OrFail` transaction forms. Not `newPivot*()` or `withPivot()`; + - `HasOneOrManyThrough`: the find-or-create methods and `BelongsToMany`'s query methods except `getResults` and `orderedChunkById`; + - `Pivot` and `MorphPivot`: `delete`. + - the schema `Builder` and the `Schema` facade: `createDatabase`, `dropDatabaseIfExists`, `getSchemas`, `hasTable`, `hasView`, `getTables`, `getTableListing`, `getViews`, `getTypes`, `hasColumn`, `hasColumns`, `whenTableHasColumn`, `whenTableDoesntHaveColumn`, `whenTableHasIndex`, `whenTableDoesntHaveIndex`, `getColumnType`, `getColumnListing`, `getColumn`, `getColumns`, `getIndexes`, `getIndexListing`, `hasIndex`, `hasForeignKey`, `getForeignKeys`, `getPartitions`, `table`, `create`, `createRangePartition`, `createMigrationRepositoryTable`, `drop`, `dropIfExists`, `dropColumns`, `truncateTables`, `rename`, `enableForeignKeyConstraints`, `disableForeignKeyConstraints`, `withoutForeignKeyConstraints`, `ensureExtensionExists`, `ensureVectorExtensionExists`, `executeBlueprint`. The base builder's `dropAllTables`, `dropAllViews` and `dropAllTypes` only throw `LogicException`, so an inherited one doesn't count; the facade adds them, since it runs the configured driver's builder. Each driver builder's own overrides that run statements count too (`MySqlBuilder`: `dropAllTables`, `dropAllViews`; `PostgresBuilder`: those plus `executeBlueprint`, `hasView`, `dropAllTypes`, `getCurrentSchemaName`; `SQLiteBuilder`: `executeBlueprint`, `getTables`, `getViews`, `getColumns`, the `*ForSchemaState` getters, `dropAllTables`, `dropAllViews`, `pragma` and the foreign key toggles, but not its database file creation and removal). +- **Redis**, on `RedisProxy` or through the `Redis` facade: commands it doesn't declare natively (resolved through `RedisCommandMethodsExtension`), `command`, `withConnection`, `withPinnedConnection`, `scan`, `hScan`, `sScan`, `zScan`, and `pipeline`/`transaction` with a callback argument. +- **HTTP client**, on `PendingRequest` or through the `Http` facade: `send`, `get`, `post`, `put`, `patch`, `delete`, `head`. +- **Waits:** `Swoole\Coroutine\Channel::push`/`pop` (including `Hypervel\Engine\Channel` and the `ChannelInterface` contract, which documents both as waiting), `WaitGroup::wait` (Hypervel and Swoole), `Waiter::wait` and `wait()`, `Parallel::wait` and `parallel()`, `Concurrent::create`/`fork`/`waitForAvailableSlot` (they wait for a free slot and throw `CanceledException` when canceled) and `WaitConcurrent::create`/`fork`/`wait`, `Concurrency::run()` (the default `coroutine` driver waits through `Parallel`), `Coroutine::join` (Hypervel, Engine and Swoole), and `Coroutine::sleep` (Hypervel and Swoole). `WaitGroup::add`/`done` and channel `close` never wait, so they aren't operations. +- **Hooked functions:** `sleep`, `usleep`. +- **Sleeps:** an expression statement discarding a `Sleep` it creates (`new Sleep` or `Sleep::for()`/`until()`/`usleep()`/`sleep()`, through its fluent methods, nullsafe ones included, such as `Sleep::for(1)->second()`), where the creating method, the constructor (the factories build with `new static`), the fluent methods, `__destruct()` and `goodnight()` are all the framework's declarations; and `Sleep::then()` wherever it's called, including when its result is assigned, on a class whose constructor and `goodnight()` are the framework's. A subclass constructor can turn sleeping off through the protected `$shouldSleep`. +- **Consumed lazy queries.** A query `Builder`'s or Eloquent builder's `cursor()` or `lazy*()` (`BuildsQueries::lazy()` runs `get()` during iteration, and `cursor()` defers the connection's cursor), including through model forwarding and the relations that declare them, counts when it's the direct subject of a `foreach` in the block or a direct `each()` call (`EnumeratesValues::each()` iterates at once), including a nullsafe `?->each()`. A framework connection's `cursor()` is a generator (`PdoConnection::cursor()` runs the query inside it), so only a `foreach` consumes it. The query method is matched by resolved declaration, so a custom `cursor()` or `lazy()` doesn't count. The builders' lazy queries return a `LazyCollection`, so `each()` is matched by name on them. Creating a first-class callable (`->each(...)`) runs nothing. A lazy result held in a variable or passed through other lazy collection methods isn't followed. + +Not operations: +- **Deferred forms**, whose work doesn't run inside the `try`: an unconsumed `cursor()` or `lazy*()`, `pipeline()`/`transaction()` without a callback, and a `Sleep` held in a variable, which sleeps when the variable releases it. The return type alone isn't evidence, since a method may return a `Sleep` held elsewhere. +- **Cache, filesystem, queue and mail calls.** This is a deliberate capability choice, not a correctness limit. The rule's docs say these calls aren't checked and can still deliver cancellation, so a broad catch around them needs the same handling. Application code reaches them through contracts and facades whose driver is configuration, so they stay unknown. Concrete I/O-backed classes (`RedisStore`, `RedisQueue`, …) could count, but code rarely calls them directly, so listing them would add upkeep for little coverage. +- **Pool and session internals** (`getPdo()`, `ping()`, `reconnect()`, `executeSessionStatement()`), the conditional `touchOwners()`/`touchIfTouching()`, and model factories, which are test and seeding code. + +**A catch propagates when** both of these hold: +- nothing leaves its body without throwing `$e`: no `return`, no `break`/`continue` leaving more loops and switches than the body opens around it (`break N` counts N levels; nested functions and classes are skipped), and no other `throw` that escapes the catch. A loop-local `break` doesn't leave the catch, but a `continue` of a loop outside the `try` does. A `throw` of `$e` or of a wrap keeping it as the previous exception (below) propagates it; any other `throw`, such as an early `throw new RuntimeException()`, swallows it unless an enclosing `try` inside the catch body catches that throw (its class from `new`, otherwise `Throwable`, against the catch types); +- its last top-level statement is `throw $e`, or `throw new X(...)` with an argument passing `$e` that binds to the native or inherited `$previous` parameter. Every argument passing `$e` is checked, so `new Wrapper($e, 'message', $e)` counts. An overriding constructor counts only when `ConstructorParser::findConstructor()` shows a top-level `parent::__construct()` passing a bound parameter as the previous argument, with no `return` and no write to that parameter (`assigns()`) before it. The constructor is parsed and its parent call found once for all the bound parameters. This is followed up the chain; +- `$e` reaches that throw unchanged. Every write to it (`assigns()`, section 2.5, which includes a nested catch binding the same name) must be a top-level `$e = new X(..., $e)` that keeps it as the previous exception by the rule above. + +Alternatively, its first top-level `Coroutine::causedByCancellation($e)` guard decides the path a cancellation takes, which must meet the same conditions: the statements before the guard, the branch it takes (a positive guard's own branch, a negated guard's `else` or nothing), and the statements after the guard unless that branch ends with a `throw`. A negated guard with `elseif`s is unknown, since their conditions run for a cancellation too. The statements before it run for a cancellation too, so they can't leave the catch or discard `$e`; a wrap keeping it as the previous exception is allowed, as in the rethrow. The exact-catch rule's `keepsCancellationApart()` still requires the guard first, since a statement before it handles the cancellation as an ordinary failure. + +`ForeignCancellationException` never counts, because `causedByCancellation()` stops at it (`Coroutine.php:251`). + +**No duplicate diagnostics.** Extract `ExactCancellationCatchRule`'s per-try verdict into `CoroutineRuleHelper::findExactCancellationMisses()`, which both rules call. The new rule skips only a catch that the existing rule reports for that try. + +`NATIVE_WAITS` (moved to `CoroutineRuleHelper`) lists the native channel, WaitGroup, Waiter and join methods a try may call and still keep an exact catch. Its comment says each either doesn't wait or throws cancellation directly, which holds in Hypervel's throwing cancellation mode. Its behavior changes through the shared receiver typing above, and its `keepsCancellationApart()` also accepts a later `Throwable` catch that handles ordinary failures inside a negated guard without `elseif`/`else` and then rethrows `$e` unchanged as its only other statement. + +**Tests:** +- a fallback `return` in a `Throwable` catch, around a query and around a channel `pop()`; +- an exact catch returning a fallback; +- a rethrow, a chained wrap and an unchained wrap; +- a caught variable overwritten before `throw $e`, including as a `foreach` value, and wrappers whose constructor overwrites the cause, including by destructuring, or forwards it only in a branch (reported); `$e = new Wrapper(..., $e); throw $e;` and a wrapper receiving `$e` as its first argument and again as `$previous` (silent); +- a query on a local builder from `Import::query()` and a pop on a locally constructed channel (reported); a typed channel replaced by a subclass overriding `pop()`, and an alias of a typed parameter that the block first replaces with that subclass (silent); +- a `where()` value, a static `whereIn()` root's value and an assigned class-string's `$model::create()` that the block sets (reported); `$app->make($class)->pop()` and an assigned `app($class)` whose class the block changes (silent); +- one try per database family (reported): `truncate()`, a model's `__call()` `increment()`, `load()`, a relation's own `get()` and a forwarded `count()`, a pivot `attach()`, a `SoftDeletes` `restore()`, the `onlyTrashed()->restore()` macro, an Eloquent collection's `load()`, `Schema::hasTable()` and `MySqlBuilder`'s own `dropAllTables()`; a model overriding `restore()`, `MySqlBuilder`'s inherited, throwing `dropAllTypes()`, a collection's `find()` and `toQuery()`, and a relation's `lazy()` (silent); +- nested tries: an inner catch swallowing a wait (inner reported, outer silent), and an inner catch rethrowing it (outer reported); +- `swallowed-cancellation-waits.php`: `WaitConcurrent::wait()`, `Concurrent::create()` and a `ChannelInterface` `pop()`, a query on a nullable connection property assigned from a `?Connection` handler parameter before the try, and the same property assigned from `DB::connection()` inside the try before a query (reported); +- `ExactCancellationCatchRule`: a channel assigned from a typed parameter is a native wait (silent); a channel property replaced in the block by a subclass overriding `pop()`, one assigned another property that the block first sets to that subclass, and a `set`-hooked property assigned a framework channel (reported); a nullable channel's `?->pop()` (silent); a negated guard reporting ordinary failures before a rethrow (silent), and a catch reporting before rethrowing (reported); +- an early `throw new RuntimeException()` before the rethrow and a cancellation guard throwing a replacement (reported); a wrap and a nested `try` catching its own throw (silent); a connection assigned with `??=` before a query (reported); +- `ForeignCancellationException`; +- a guard with an early return, and a negated guard reporting and returning before a rethrow (silent); +- a `report()` call before a positive guard that rethrows and before a negated guard followed by a rethrow (silent); a conditional `return` before the guard, and `$e` replaced with an unrelated exception before it (reported); an inner try reporting before its guard inside an outer catch returning a fallback (inner silent, outer reported); a positive guard falling through to a rethrow, with an `else` that returns, and a negated guard whose `else` reports before the rethrow (silent), and a positive guard falling through to a `return` (reported); +- a discarded `Sleep::usleep()`, a fluent `Sleep::for(1)->second()->…` and a nullsafe `Sleep::for(1)?->second()`, `then()` and an assigned `then()` result (reported); a held `Sleep`'s modifier, a subclass with a no-op `goodnight()` both discarded and through `then()`, and a subclass whose constructor turns sleeping off, created through `for()`, both discarded and through `then()` (silent); +- a loop- and switch-local `break`/`continue 2` before a rethrow (silent), and a `continue` of a loop around the `try` (reported); +- a try with only deferred forms (`cursor()`, `Redis::pipeline()`), silent; +- a `RedisProxy` `pipeline()` with a callback in a job (reported); +- consumed lazy queries: `foreach` over a model's `lazy()`, a relation's `lazy()` and a connection's `cursor()`, `each()` directly on a builder's `cursor()`, and a nullsafe `?->each()` on a model's `lazy()` (reported); a lazy query held in a variable before its `foreach`, one passed through `map()` before `each()`, and a first-class `each(...)` callable (silent); +- a `??=` on a non-null parameter whose class overrides `pop()` with no wait, assigning a framework channel before the `pop()` (silent), and on a non-null framework channel whose never-run assigned expression reads a class name the block sets (reported); +- the same try outside any context, silent; +- the existing rule's case producing exactly one diagnostic; +- the warm-cache test for a constructor forwarding `$previous` (section 2.5). + +## 4. Docs + +- `src/docs/static-analysis.md`: + - one section per rule in section 3, placed with related sections (worker state, cancellation, configuration, dates, Eloquent, container), with the switch listed in the configuration reference; + - in PR 1's Method Attributes section, add that the analysis reports calls to lifetime-marked methods from request, job and operation code (applying the conditions) and calls that reach an `#[Unsupported]` method, including its message, linking to the rule sections; + - extend Worker State's "Request and job checks cover …" sentence with the listener, observer and server-operation contexts from section 2.3. +- `src/docs/facades.md`, `src/docs/porting-from-laravel.md` and `src/support/README.md` (sections 1.2, 1.3 and 3.5). +- Don't repeat the rule catalogue elsewhere. + +## 5. Verification + +Run sequentially from the components root: +1. Each changed or new test file immediately. +2. `composer analyse`: fix or ignore with reasons all new framework reports; global NEON ignores need consensus plus owner approval. +3. `composer test:phpstan`. +4. Once section 1 is done, one `composer test:parallel -- --processes=4 tests/Support` run, which holds the facade swap and mock tests the facade change can affect. Section 1.4 is PHPDoc only, so `composer analyse` covers it. The full suite is left to CI. +5. `COMPOSER_PROCESS_TIMEOUT=0 composer lint:fix`. +6. Once stable, measure cold and warm `composer analyse` time against `0.4`, after the owner confirms the machine is idle. + +Monorepo consumers: no class in `packages/hypervel/src` or `apps` implements the container contract, implements or extends a connection resolver, or subclasses the `Date` facade (checked by grep). Section 1.5 changed two package tests' connection doubles to `Connection` on monorepo `main`. The monorepo's hypervel/phpstan adoption remains owner-gated. + +## 6. `AGENTS.md` (agreed wording; PR 1's edits already applied) + +1. In "Mark restricted worker-state mutators", insert after the attribute list: + > PHPStan checks marked calls in recognized runtime contexts. +2. Append to "Mark intentionally unsupported methods": + > PHPStan reports calls that reach a marked method. +3. Replace binding pattern 3, including its code block, with: + > **3. Abstract and concrete are the same class — do not bind merely to share it.** Unbound concrete classes without a different declared lifetime are auto-singletons. Explicit registration can change lifetimes, `bound()`, optional constructor injection and rebinding; retain it when those effects are intended. +4. Replace the last sentence of the alias paragraph, "Binding an alias instead of the canonical key can orphan the binding.", with: + > Binding an alias removes its mapping to the canonical key; the two keys can then resolve differently. + +## Status + +Done: sections 1 to 6, including the benchmark, Testbench runtime-copy cache fix (section 2.5), final integration review, reviewed bot corrections and the section 3.3 statement-order redesign. Next: final review of the section 3.3 redesign, then push to both remotes, reply to the remaining bot comments, and complete the next CI and review round. diff --git a/docs/todo.md b/docs/todo.md index 97a2d8dd5..693fc6309 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -21,6 +21,8 @@ ## Framework-wide +- In a separate PR, make factory-created dates immutable by contract, removing the application-wide mutable opt-out from `Date` / `DateFactory`, `now()` and `today()`. Audit custom classes, factories, callbacks and consumers; preserve useful timezone, locale and test-clock customization and immutable subclasses. Keep explicit mutable conversion at third-party boundaries. Update runtime enforcement, return types, tests and docs together, then tighten `hypervel/phpstan` typing and discarded-date diagnostics wherever immutability is guaranteed; retain accurate handling of external mutable dates. Review the Laravel API departures before implementation. + - Once a released `nesbot/carbon` version fixes generated fluent return annotations to preserve subclasses, raise the applicable Carbon minimum through Composer and remove the redundant `@method static` overrides from `Hypervel\Support\CarbonImmutable` (`src/support/src/CarbonImmutable.php`). Carbon's `phpdoc.php::compileDoc()` currently emits concrete `CarbonImmutable`/`CarbonInterface` return types for these methods even though runtime preserves the calling class. Retain the existing overrides until a fixed release is verified; do not expand them into a duplicate of Carbon's complete method list. Keep `types/Support/CarbonImmutable.php` as dependency regression coverage, verify further subclasses as well, and remove `SupportCarbonImmutableTest::testMagicModifierMetadataMatchesRuntimeStaticType()` if no local overrides remain. - Investigate a small shared serializer contract with native PHP and igbinary implementations for storage and transport boundaries where configurable encoding provides a measured benefit. Select the implementation per store or subsystem, resolve it once during service construction, and keep the selection fixed during worker execution; an optional facade can expose application access. Benchmark representative cache values, sessions, and jobs for encode/decode time, payload size, and complete storage or transport cost, including any base64 encoding. Igbinary can reduce size and decode time for repeated data, but can encode small payloads more slowly; an `auto` option would express a documented preference among compatible installed serializers, not discover the fastest one or benchmark every call. Audit call sites rather than replacing every `serialize()` / `unserialize()` call: preserve native format contracts and internal key generation, reuse existing PhpRedis serialization and Lua packing, and preserve class restrictions (`igbinary_unserialize()` has no native `allowed_classes` equivalent). Queue JSON payloads and their native `O:` detection need explicit format and reader changes before supporting binary encoding. Define format identification and migration so persisted values remain readable across configuration changes and rolling deployments. Use Symfony Cache's `MarshallerInterface` / `DefaultMarshaller` as prior art rather than its object-normalizing Serializer component. GMP is not a general serializer; native PHP and igbinary already round-trip nested GMP values, while `gmp_export()` only exports an integer's magnitude and loses its sign. diff --git a/phpunit.phpstan.xml.dist b/phpunit.phpstan.xml.dist index ac8cee358..a9a74b2a6 100644 --- a/phpunit.phpstan.xml.dist +++ b/phpunit.phpstan.xml.dist @@ -4,6 +4,7 @@ beStrictAboutTestsThatDoNotTestAnything="false" colors="true" defaultTimeLimit="60" + timeoutForLargeTests="180" enforceTimeLimit="true" failOnRisky="true" failOnWarning="true" diff --git a/skeletons/hypervel/config/logging.php b/skeletons/hypervel/config/logging.php index ae02eeb61..447115ca9 100644 --- a/skeletons/hypervel/config/logging.php +++ b/skeletons/hypervel/config/logging.php @@ -143,7 +143,7 @@ 'syslog' => [ 'driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), - 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'facility' => env('LOG_SYSLOG_FACILITY', 'user'), 'replace_placeholders' => true, ], diff --git a/skeletons/react-starter-kit/config/logging.php b/skeletons/react-starter-kit/config/logging.php index ae02eeb61..447115ca9 100644 --- a/skeletons/react-starter-kit/config/logging.php +++ b/skeletons/react-starter-kit/config/logging.php @@ -143,7 +143,7 @@ 'syslog' => [ 'driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), - 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'facility' => env('LOG_SYSLOG_FACILITY', 'user'), 'replace_placeholders' => true, ], diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index f1de46cae..0dc628de5 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -360,6 +360,11 @@ public function getTagMode(): TagMode * * All Redis operations inside the callback reuse the same connection, * avoiding multiple pool checkouts. + * + * @template T + * + * @param callable(): T $callback + * @return T */ public function withPinnedConnection(callable $callback): mixed { diff --git a/src/container/src/Container.php b/src/container/src/Container.php index c53cea13a..127e9aa4e 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -440,6 +440,18 @@ public function resolvedScoped(string $abstract): bool && $this->isScoped($abstract)); } + /** + * Determine if the container holds a worker-shared instance for the given type. + */ + public function resolvedShared(string $abstract): bool + { + $abstract = $this->getAlias($abstract); + + return isset($this->instances[$abstract]) + || array_key_exists($abstract, $this->instances) + || isset($this->autoSingletons[$abstract]); + } + /** * Determine if a ReflectionClass has scoping attributes applied. * diff --git a/src/contracts/README.md b/src/contracts/README.md index cb1d1fca7..f1136741f 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -7,6 +7,6 @@ Documentation: https://hypervel.org/docs/contracts ## Differences From Laravel -The `Container` contract also requires `isScoped()`, `isScopedInstance()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Scope inspection lets consumers preserve coroutine-local lifetimes instead of caching scoped services across requests. +The `Container` contract also requires `isScoped()`, `isScopedInstance()`, `resolvedShared()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Lifetime inspection lets consumers cache only the instances the container shares for the worker, instead of caching scoped or fresh services across requests. Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts diff --git a/src/contracts/src/Container/Container.php b/src/contracts/src/Container/Container.php index e8dbb54b8..ca0f9c6b1 100644 --- a/src/contracts/src/Container/Container.php +++ b/src/contracts/src/Container/Container.php @@ -98,6 +98,11 @@ public function isScoped(string $abstract): bool; */ public function isScopedInstance(mixed $instance): bool; + /** + * Determine if the container holds a worker-shared instance for the given type. + */ + public function resolvedShared(string $abstract): bool; + /** * "Extend" an abstract type in the container. * diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index 8d259b5b1..d545d2eef 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -155,6 +155,7 @@ public static function forkOwned(callable $callable, Closure $wrapper, array $ke */ private static function createWithContext(callable $callable, array $context, ?Closure $wrapper): int { + // @phpstan-ignore hypervel.rawCoroutine (the framework wrapper: its callback catches and reports every exception) $coroutine = Co::create(static function () use ($callable, $context, $wrapper): void { try { if ($wrapper === null) { diff --git a/src/database/README.md b/src/database/README.md index b18d41e72..d1319a203 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -28,6 +28,7 @@ Documentation: https://hypervel.org/docs/database - Laravel's deprecated database-inspection forwarding helpers are intentionally not ported. Extensions can call `ConnectionInterface::getDriverTitle()` and `threadCount()` directly. - Laravel's remaining directly deprecated Database compatibility forwarders are intentionally not ported. Use the current class-keyed factory resolver, schema blueprint and grammar APIs, and correctly named PostgreSQL truncation method instead. - Laravel's `ConnectionResolver` is a simple registry of connection instances. Hypervel's `ConnectionResolver` is the pooled resolver built by the container, so it has no array constructor or `addConnection()` method. Tests that inject existing connections can mock `ConnectionResolverInterface`, while tests that need real configured connections can use the Capsule manager. +- `ConnectionResolverInterface::connection()` returns the driver-neutral `Connection` base class instead of Laravel's `ConnectionInterface`, because Eloquent, migrations and the database manager rely on it. Custom resolvers must return a `Connection`, and test doubles returned from a resolver's or the `DB` facade's `connection()` must mock `Connection`. - Laravel's Capsule manager exposes a `setFetchMode()` method that writes configuration its connections do not read. Hypervel omits this ineffective connection-wide setter; use `Query\Builder::fetchUsing()` for each query that needs a custom row shape. - `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files. - `Blueprint::dropForeign()` widens Laravel's method signature with an optional constraint name when columns are supplied, allowing explicitly named foreign keys to be dropped portably across SQLite and the server databases. Custom `Blueprint` subclasses that override this method must accept the optional second argument. diff --git a/src/database/src/CachedConnectionResolver.php b/src/database/src/CachedConnectionResolver.php index 95c0031d2..ec1d9251c 100644 --- a/src/database/src/CachedConnectionResolver.php +++ b/src/database/src/CachedConnectionResolver.php @@ -12,7 +12,7 @@ interface CachedConnectionResolver /** * Get an already resolved connection from the cache. */ - public function getResolvedConnection(string $name): ?ConnectionInterface; + public function getResolvedConnection(string $name): ?Connection; /** * Flush a cached connection. diff --git a/src/database/src/Capsule/Manager.php b/src/database/src/Capsule/Manager.php index 7eabe10c2..9db7533ef 100644 --- a/src/database/src/Capsule/Manager.php +++ b/src/database/src/Capsule/Manager.php @@ -9,7 +9,7 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\DatabaseManager; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; @@ -86,7 +86,7 @@ protected function setupManager(): void /** * Get a connection instance from the global manager. */ - public static function connection(?string $connection = null): ConnectionInterface + public static function connection(?string $connection = null): Connection { return static::$instance->getConnection($connection); } @@ -110,7 +110,7 @@ public static function schema(?string $connection = null): SchemaBuilder /** * Get a registered connection instance. */ - public function getConnection(?string $name = null): ConnectionInterface + public function getConnection(?string $name = null): Connection { return $this->manager->connection($name); } diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index f27520929..2c5d68320 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -65,7 +65,7 @@ public function __construct( * Store a stable logical connection in the current coroutine's context. * The current physical lease is settled when the coroutine ends. */ - public function connection(UnitEnum|string|null $name = null): ConnectionInterface + public function connection(UnitEnum|string|null $name = null): Connection { $connectionName = ConnectionName::parse($this->connectionName($name)); $connectionOwnerName = $connectionName->requested; @@ -73,7 +73,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa $connection = CoroutineContext::get($contextKey); - if ($connection instanceof ConnectionInterface) { + if ($connection instanceof Connection) { return $connection; } @@ -88,8 +88,8 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa $connection = CoroutineContext::get($contextKey); - if ($connection instanceof ConnectionInterface) { - if ($connectionName->isWrite() && $connection instanceof Connection) { + if ($connection instanceof Connection) { + if ($connectionName->isWrite()) { $connection->useWriteConnectionWhenReading(); } @@ -164,7 +164,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa /** * Get the current execution's connection without borrowing a pool slot. */ - public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface + public function getHeldConnection(UnitEnum|string|null $name = null): ?Connection { return $this->getContextConnection($this->connectionName($name)); } @@ -172,12 +172,12 @@ public function getHeldConnection(UnitEnum|string|null $name = null): ?Connectio /** * Find the context-owned connection, including shared in-memory aliases. */ - private function getContextConnection(string $name): ?ConnectionInterface + private function getContextConnection(string $name): ?Connection { $connectionName = ConnectionName::parse($name); $held = CoroutineContext::get($this->getContextKey($connectionName->requested)); - if ($held instanceof ConnectionInterface) { + if ($held instanceof Connection) { return $held; } @@ -187,7 +187,7 @@ private function getContextConnection(string $name): ?ConnectionInterface if ($pool?->getSharedInMemorySqlitePdo() !== null) { $held = CoroutineContext::get($this->getContextKey($pool->getName())); - if ($held instanceof ConnectionInterface) { + if ($held instanceof Connection) { return $held; } } @@ -202,7 +202,7 @@ public function hasApplicableTransaction(UnitEnum|string|null $name = null): boo { $held = $this->getContextConnection($this->connectionName($name)); - return $held instanceof Connection && $held->hasApplicableTransaction(); + return $held !== null && $held->hasApplicableTransaction(); } /** @@ -238,7 +238,6 @@ public function connectionDateFormat(UnitEnum|string|null $name = null): string } } - /** @var Connection $connection */ $connection = $this->connection($name); try { diff --git a/src/database/src/ConnectionResolverInterface.php b/src/database/src/ConnectionResolverInterface.php index 0fc5bcfc4..89b9ddbb7 100644 --- a/src/database/src/ConnectionResolverInterface.php +++ b/src/database/src/ConnectionResolverInterface.php @@ -10,8 +10,11 @@ interface ConnectionResolverInterface { /** * Get a database connection instance. + * + * Unlike Laravel's ConnectionInterface, this returns the Connection base class, which every + * driver extends, because Eloquent, migrations and the manager rely on it. */ - public function connection(UnitEnum|string|null $name = null): ConnectionInterface; + public function connection(UnitEnum|string|null $name = null): Connection; /** * Get the default connection name. diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index 5d80b93f5..4a2f1455d 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -94,7 +94,7 @@ public function __construct( * Resolves the default connection name here (checking Context for usingConnection override) * before passing to the resolver. */ - public function connection(UnitEnum|string|null $name = null): ConnectionInterface + public function connection(UnitEnum|string|null $name = null): Connection { if ($name instanceof UnitEnum) { $name = (string) enum_value($name); @@ -112,7 +112,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa * * Resolvers without connection inspection support resolve normally. */ - public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface + public function getHeldConnection(UnitEnum|string|null $name = null): ?Connection { if ($name instanceof UnitEnum) { $name = (string) enum_value($name); @@ -148,10 +148,7 @@ public function hasApplicableTransaction(UnitEnum|string|null $name = null): boo return $resolver->hasApplicableTransaction($name); } - /** @var Connection $connection */ - $connection = $resolver->connection($name); - - return $connection->hasApplicableTransaction(); + return $resolver->connection($name)->hasApplicableTransaction(); } /** @@ -176,10 +173,7 @@ public function connectionDateFormat(UnitEnum|string|null $name = null): string return $resolver->connectionDateFormat($name); } - /** @var Connection $connection */ - $connection = $resolver->connection($name); - - return $connection->getQueryGrammar()->getDateFormat(); + return $resolver->connection($name)->getQueryGrammar()->getDateFormat(); } /** @@ -191,7 +185,7 @@ public function connectionDateFormat(UnitEnum|string|null $name = null): string * * @internal For use by SimpleConnectionResolver only */ - public function resolveConnectionDirectly(string $name): ConnectionInterface + public function resolveConnectionDirectly(string $name): Connection { $connectionName = ConnectionName::parse($name); @@ -409,7 +403,7 @@ public function disconnect(UnitEnum|string|null $name = null): void $resolver = $this->app->make('db.resolver'); if ($resolver instanceof CachedConnectionResolver - && ($connection = $resolver->getResolvedConnection($connectionName->requested)) instanceof Connection + && ($connection = $resolver->getResolvedConnection($connectionName->requested)) !== null ) { $connection->disconnect(); } @@ -467,7 +461,7 @@ public function reconnect(UnitEnum|string|null $name = null): Connection $resolver = $this->app->make('db.resolver'); if ($resolver instanceof CachedConnectionResolver - && ($connection = $resolver->getResolvedConnection($name)) instanceof Connection + && ($connection = $resolver->getResolvedConnection($name)) !== null ) { $connection->reconnect(); @@ -475,7 +469,6 @@ public function reconnect(UnitEnum|string|null $name = null): Connection } // No existing connection — get a fresh one - // @phpstan-ignore return.type (connection() returns ConnectionInterface but concrete Connection in practice) return $this->connection($name); } diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index f9db09be0..03a45a648 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -1605,6 +1605,7 @@ protected function serializeDate(DateTimeInterface $date): string { return $date instanceof DateTimeImmutable ? CarbonImmutable::instance($date)->toJSON() + // @phpstan-ignore hypervel.mutableDate (copies the date only to serialize it, keeping a mutable Carbon subclass's toJSON()) : Carbon::instance($date)->toJSON(); } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 04df15310..4e250c072 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -2569,7 +2569,6 @@ public function setConnection(UnitEnum|string|null $name): static */ public static function resolveConnection(UnitEnum|string|null $connection = null): Connection { - // @phpstan-ignore return.type (resolver interface returns ConnectionInterface, but concrete always returns Connection) return static::$resolver->connection($connection); } diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index c52b17bca..a75fe7df1 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -841,7 +841,7 @@ public function resolveConnection(?string $connection): Connection $connection ); } - // @phpstan-ignore return.type (resolver returns ConnectionInterface but concrete Connection in practice) + return $this->resolver->connection($connection); } diff --git a/src/database/src/SimpleConnectionResolver.php b/src/database/src/SimpleConnectionResolver.php index f460d1542..7455e7854 100644 --- a/src/database/src/SimpleConnectionResolver.php +++ b/src/database/src/SimpleConnectionResolver.php @@ -38,7 +38,7 @@ public function __construct( * Delegates to DatabaseManager::resolveConnectionDirectly() which manages * connections in a simple array without pooling. */ - public function connection(UnitEnum|string|null $name = null): ConnectionInterface + public function connection(UnitEnum|string|null $name = null): Connection { if ($name instanceof UnitEnum) { $name = (string) enum_value($name); diff --git a/src/docs/ai-sdk.md b/src/docs/ai-sdk.md index 9abf8aa32..8a1577a94 100644 --- a/src/docs/ai-sdk.md +++ b/src/docs/ai-sdk.md @@ -1145,7 +1145,7 @@ Reasoning is also available on responses returned by the `prompt` method. Streams start their provider work when consumed. A completed stream retains its events for replay and aggregation, so memory use grows with the response. The native HTTP transport receives incremental data and bounds its own unread buffer; see [HTTP streaming](/docs/{{version}}/http-client#streaming-responses) for timeout and fallback behavior. -When returned as an HTTP response, the SDK enables [disconnect cancellation](/docs/{{version}}/responses#streamed-responses) for its SSE and protocol responses. A supported whole-connection close cancels owned provider work. Individual HTTP/2 stream resets are not reported immediately by the current runtime; detection waits for a failed write, completion, or timeout. +When returned as an HTTP response, the SDK enables [disconnect cancellation](/docs/{{version}}/responses#canceling-disconnected-streams) for its SSE and protocol responses. A supported whole-connection close cancels owned provider work. Individual HTTP/2 stream resets are not reported immediately by the current runtime; detection waits for a failed write, completion, or timeout. #### Capturing Application Context diff --git a/src/docs/container.md b/src/docs/container.md index 97fb86031..ef2cbea9f 100644 --- a/src/docs/container.md +++ b/src/docs/container.md @@ -404,7 +404,9 @@ use Hypervel\Contracts\Container\Container; #[BindWhen( RedisEventPusher::class, - static fn (Container $container): bool => $container->bound('redis'), + static function (Container $container): bool { + return $container->bound('redis'); + }, )] interface EventPusher { diff --git a/src/docs/facades.md b/src/docs/facades.md index eafcb0269..e33086ee8 100644 --- a/src/docs/facades.md +++ b/src/docs/facades.md @@ -193,7 +193,7 @@ class Cache extends Facade Instead, the `Cache` facade extends the base `Facade` class and defines the method `getFacadeAccessor()`. This method's job is to return the name of a service container binding. When a user references any static method on the `Cache` facade, Hypervel resolves the `cache` binding from the [service container](/docs/{{version}}/container) and runs the requested method (in this case, `get`) against that object. -By default, a facade keeps the first instance it resolves for the lifetime of the worker, even when the service uses a `bind()` binding. Scoped services are the exception, including when a `bind()` binding or factory returns a scoped instance. Register a service with `scoped()` to share one instance within each request or job coroutine. To resolve a fresh instance from a `bind()` binding on every facade call, set `protected static bool $cached = false` on the facade. +Facades reuse services the container shares, such as singletons, instance registrations and auto-singletons, for the lifetime of the worker. For services registered with `bind()`, or unbound classes marked `Transient`, a facade keeps one instance for the current request or job coroutine. Scoped services always resolve from the current coroutine, including when a `bind()` binding returns a scoped instance. To resolve the service on every facade call, set `protected static bool $cached = false` on the facade. ## Facade Class Reference diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 051437efa..18efd5bd1 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -28,7 +28,7 @@ - [Scheduling](#scheduling) - [Maintenance Mode](#maintenance-mode) - [HTTP Client and Concurrency](#http-client-and-concurrency) - - [Streamed Responses](#streamed-responses) + - [Native Request Functions](#native-request-functions) - [AI SDK](#ai-sdk) - [MCP](#mcp) - [Saloon](#saloon) @@ -435,8 +435,6 @@ Container lifecycles are adapted for Swoole: > [!WARNING] > Unbound concrete classes are automatically cached for the worker lifetime after their first resolution. If an unbound class captures the current user, tenant, request, or other mutable per-request data in its constructor, ordinary tests may pass while concurrent requests receive another request's state. Register the class with `bind()` for a fresh instance, use `scoped()` for one instance per request or job coroutine, call `makeTransient()` for one normal resolution without implicit auto-singletoning or constructor-derived execution scope, construct a guaranteed fresh instance with `build()`, or implement `Transient` when every subclass must always be fresh. Hypervel's intrinsically fresh model, value, builder, pending-request, pipeline, and response families already implement `Transient`; see [Transient Classes](/docs/{{version}}/container#transient-classes) for the current list and binding behavior. -Facades cache non-scoped services for the worker lifetime, including `bind()` bindings; use `scoped()` for request or job state. Disabling the facade's cache resolves the service on every call, returning fresh instances only for `bind()` bindings and `Transient` classes; singletons and auto-singletons still return the same instance. See [how facades work](/docs/{{version}}/facades#how-facades-work). - Hypervel treats a contextual attribute's resolved value as authoritative, including `null`. Laravel constructor injection may fall through from `null` to class or primitive resolution, a contextual binding, or a declared default. Move that fallback into the attribute resolver when porting code that relies on this behavior. On PHP 8.5 and later, `#[BindWhen]` conditions must depend only on boot-stable state. A matching condition becomes a normal worker-lifetime binding in Hypervel, while an unmatched condition may be evaluated again on a later resolution. Do not read the current request, user, or tenant from the condition. @@ -549,8 +547,10 @@ Hypervel installs `hypervel/guzzle`, a Guzzle 8 fork that replaces `guzzlehttp/g Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver). - -### Streamed Responses + +### Native Request Functions + +PHP's native request functions act on the worker process, not the request being handled. Replace `header()`, `setcookie()` and `http_response_code()` with the response's `header()`, `cookie()` and `setStatusCode()` methods. Read input and headers from the request instead of `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_REQUEST`, `getallheaders()` and the `filter_input()` functions. Use the session store instead of PHP's `session_*` functions and `$_SESSION`, which is shared by every request in the worker. The `session_create_id()` function only generates an ID and remains usable. [Static analysis](/docs/{{version}}/static-analysis#native-request-apis) reports the request-state APIs. `connection_aborted()` does not report Swoole client disconnects. Replace loops that rely on it with a generator callback passed to `response()->stream()`, or an `IterableStreamedResponse`. See [Canceling Disconnected Streams](/docs/{{version}}/responses#canceling-disconnected-streams) to also cancel production during a wait. @@ -777,6 +777,8 @@ $expiresAt = $expiresAt->addMinutes(5); Review concrete `Hypervel\Support\Carbon` type declarations that receive factory-created values. Use `Carbon\CarbonInterface` at boundaries that may receive mutable or immutable dates, or `CarbonImmutable` when immutability is required. An application that deliberately requires mutable dates may configure the date factory during application boot. See the [date and time documentation](/docs/{{version}}/helpers#dates). +The framework binds `date` to `DateFactory`, so a `Date` facade subclass that overrides `DEFAULT_FACADE` no longer supplies the factory in a booted application. Bind `date` to your factory in a service provider instead. + ### UUIDs @@ -848,6 +850,8 @@ Do not manually commit or roll back the transaction owned by a `DB::transaction( If your extension inspects transaction records or registers callbacks directly with `DatabaseTransactionsManager`, use the resolved connection's `getNameWithReadWriteType()` instead of `getName()`. Base, `::read`, and `::write` connections can have independent transactions. Always resolve the connection before reading its name; aliases for a shared in-memory SQLite connection resolve to the same session. Ordinary `afterCommit()` and `afterRollBack()` calls already use the correct connection. +Custom `ConnectionResolverInterface` implementations must return a `Connection` from `connection()`, not any `ConnectionInterface`. In tests, mock `Connection` for the connections a mocked resolver, database manager or `DB::shouldReceive('connection')` returns. + When a package constructs `DatabaseStore`, `DatabaseSessionHandler`, `DatabaseQueue`, or `DatabaseBatchRepository` directly, pass the database connection resolver and configured connection name instead of retaining a resolved connection. Framework-configured drivers already use this form. Laravel's `ConnectionResolver` can be built from an array of connection instances, but Hypervel's `ConnectionResolver` is the pooled resolver created by the container. Where package tests build Laravel's resolver directly, mock `ConnectionResolverInterface` to inject existing connections, or use the Capsule manager for real configured connections. diff --git a/src/docs/static-analysis.md b/src/docs/static-analysis.md index a61ba2123..915fe982e 100644 --- a/src/docs/static-analysis.md +++ b/src/docs/static-analysis.md @@ -25,29 +25,46 @@ - [The `relation-of` Type](#relation-of) - [Fixing and Ignoring Diagnostics](#fixing-and-ignoring-diagnostics) - [Environment Variables](#environment-variables) + - [Casting Environment Variables](#casting-environment-variables) - [Configuration Values](#configuration-values) + - [Typed Configuration Getters](#typed-configuration-getters) - [Array Keys](#array-keys) - [Value and With Helpers](#value-and-with-helpers) - [Converting Collections to Arrays](#converting-collections-to-arrays) + - [Mutable Dates](#mutable-dates) + - [Discarded Dates](#discarded-dates) - [Translations](#translations) - [Views](#views) - [Console Input](#console-input) + - [Native Request APIs](#native-request-apis) - [Authentication in Requests](#authentication-in-requests) - [Redis Connections](#redis-connections) + - [Borrowed Resources](#borrowed-resources) - [Cancellation](#cancellation) + - [Swallowed Cancellation](#swallowed-cancellation) + - [Raw Coroutines](#raw-coroutines) - [Coroutines in Transactions](#coroutines-in-transactions) + - [Managed Transactions](#managed-transactions) - [Sleeping](#sleeping) - [Worker State](#worker-state) + - [Restricted Methods](#restricted-methods) + - [Unsupported Methods](#unsupported-methods) - [Runtime Configuration](#runtime-configuration) + - [Socialite Providers](#socialite-providers) + - [Swoole Tables](#swoole-tables) + - [Binding Cycles](#binding-cycles) + - [Conditional Bindings](#conditional-bindings) - [Model Relations](#model-relations) - [Model Columns](#model-columns) - [Model Scopes and Accessors](#model-scopes-and-accessors) - [Model Configuration](#model-configuration) + - [Model Observers](#model-observers) - [Appended Attributes](#appended-attributes) - [Creating Models](#creating-models) - [Query Results](#query-results) - [Starting Queries](#starting-queries) - [Schema Dumps](#schema-dumps) + - [Migration Column Types](#migration-column-types) - [Dispatching Jobs and Events](#dispatching-jobs-and-events) - [Dispatching Jobs in Transactions](#dispatching-jobs-in-transactions) - [Models in Queued Jobs](#models-in-queued-jobs) @@ -118,23 +135,36 @@ Parameters that accept directories take absolute paths, or paths relative to the | Parameter | Default | Described In | |---|---|---| | `noEnvCallsOutsideOfConfig` | `true` | [Environment Variables](#environment-variables) | +| `checkConfigEnvCasts` | `true` | [Casting Environment Variables](#casting-environment-variables) | | `checkConfigTypes` | `true` | [Configuration Values](#configuration-values) | | `configDirectories` | `[]` | [Configuration Values](#configuration-values) | +| `checkTypedConfigGetters` | `true` | [Typed Configuration Getters](#typed-configuration-getters) | | `noUnnecessaryEnumerableToArrayCalls` | `true` | [Converting Collections to Arrays](#converting-collections-to-arrays) | +| `noMutableDates` | `true` | [Mutable Dates](#mutable-dates) | +| `checkDiscardedDates` | `true` | [Discarded Dates](#discarded-dates) | | `checkMissingTranslations` | `true` | [Translations](#translations) | | `translationDirectories` | `[]` | [Translations](#translations) | | `checkUnusedViews` | `false` | [Views](#views) | | `viewDirectories` | `[]` | [Views](#views) | +| `noNativeRequestApis` | `true` | [Native Request APIs](#native-request-apis) | | `checkAuthCallsWhenInRequestScope` | `false` | [Authentication in Requests](#authentication-in-requests) | | `checkRedisConnectionBoundMethods` | `true` | [Redis Connections](#redis-connections) | -| `checkCancellationHandling` | `true` | [Cancellation](#cancellation) | +| `checkBorrowedResources` | `true` | [Borrowed Resources](#borrowed-resources) | +| `checkCancellationHandling` | `true` | [Cancellation](#cancellation) and [Swallowed Cancellation](#swallowed-cancellation) | | `checkCancellationPrecedence` | `true` | [Cancellation](#cancellation) | +| `noRawCoroutines` | `true` | [Raw Coroutines](#raw-coroutines) | | `noCoroutinesInTransactions` | `true` | [Coroutines in Transactions](#coroutines-in-transactions) | +| `checkManagedTransactions` | `true` | [Managed Transactions](#managed-transactions) | | `noRawSleepCalls` | `true` | [Sleeping](#sleeping) | | `checkWorkerSharedState` | `true` | [Worker State](#worker-state) | | `checkStaticStateMutation` | `true` | [Worker State](#worker-state) | | `checkUnboundedWorkerCaches` | `true` | [Worker State](#worker-state) | +| `checkRestrictedCalls` | `true` | [Restricted Methods](#restricted-methods) | | `noRuntimeConfigMutation` | `true` | [Runtime Configuration](#runtime-configuration) | +| `checkSocialiteProviders` | `true` | [Socialite Providers](#socialite-providers) | +| `checkSwooleTables` | `true` | [Swoole Tables](#swoole-tables) | +| `checkBindingCycles` | `true` | [Binding Cycles](#binding-cycles) | +| `checkBindWhenConditions` | `true` | [Conditional Bindings](#conditional-bindings) | | `databaseMigrationsPath` | `[]` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | | `squashedMigrationsPath` | `[]` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | | `disableMigrationScan` | `false` | [Migrations and Schema Dumps](#migrations-and-schema-dumps) | @@ -146,12 +176,14 @@ Parameters that accept directories take absolute paths, or paths relative to the | `noLegacyModelScopes` | `true` | [Model Scopes and Accessors](#model-scopes-and-accessors) | | `checkModelMethodVisibility` | `true` | [Model Scopes and Accessors](#model-scopes-and-accessors) | | `noLegacyModelProperties` | `true` | [Model Configuration](#model-configuration) | +| `checkObserverAttributes` | `true` | [Model Observers](#model-observers) | | `checkModelAppends` | `true` | [Appended Attributes](#appended-attributes) | | `noModelMake` | `true` | [Creating Models](#creating-models) | | `noUnnecessaryCollectionCall` | `true` | [Query Results](#query-results) | | `noUnnecessaryCollectionCallOnly` | `[]` | [Query Results](#query-results) | | `noUnnecessaryCollectionCallExcept` | `[]` | [Query Results](#query-results) | | `noImplicitQueryBuilderCall` | `false` | [Starting Queries](#starting-queries) | +| `checkMigrationColumnTypes` | `false` | [Migration Column Types](#migration-column-types) | | `checkDispatchInTransactionAfterCommit` | `true` | [Dispatching Jobs in Transactions](#dispatching-jobs-in-transactions) | | `checkJobSerializesModels` | `true` | [Models in Queued Jobs](#models-in-queued-jobs) | | `checkBatchedJobIsBatchable` | `true` | [Batched Jobs](#batched-jobs) | @@ -180,7 +212,7 @@ return ['enabled' => (bool) env('FEATURE_ENABLED', false)]; $enabled = config('feature.enabled'); ``` -Configuration types assume you are reading your application's configuration repository. If you build a separate repository, such as `new Repository($items)`, add a PHPDoc type to the values you read from it, or ignore a reported call with a reason when needed. +PHPStan infers configuration value types through the `config` helper, the `Config` facade and calls such as `app('config')->get('app.name')` that resolve the application's repository. A repository received as a parameter or property, or created with `new Repository($items)`, may hold different values. Those reads keep the repository's declared return types. PHPStan boots your application to discover its services, authentication guards, commands, views and translations. Run analysis with the same environment locally and in CI, since settings such as `DB_CONNECTION` and `AUTH_MODEL` affect the inferred types. Package analysis uses your Testbench application's registrations. @@ -515,7 +547,7 @@ public function callAsync(string $method, array $arguments = []): never } ``` -These attributes describe intended usage. They are not enforced at runtime, and the analysis does not check calls to marked methods. +These attributes are not enforced at runtime. Instead, the analysis reports calls to boot-time and test-only methods from request, job and operation code, applying the attribute's conditions, as described in [Restricted Methods](#restricted-methods). It also reports calls that reach an unsupported method, along with the attribute's message, as described in [Unsupported Methods](#unsupported-methods). ## Custom PHPDoc Types @@ -642,29 +674,46 @@ The following sections explain the diagnostics and their remedies. Use this tabl | Topic | Identifiers | |---|---| | [Environment Variables](#environment-variables) | `hypervel.noEnvCallsOutsideOfConfig` | +| [Casting Environment Variables](#casting-environment-variables) | `hypervel.uncastEnvValue` | | [Configuration Values](#configuration-values) | `hypervel.configType`, `hypervel.configCollection` | +| [Typed Configuration Getters](#typed-configuration-getters) | `hypervel.typedConfigGetter` | | [Array Keys](#array-keys) | `offsetAccess.notFound` | | [Value and With Helpers](#value-and-with-helpers) | `hypervel.uselessConstructs.value`, `hypervel.uselessConstructs.with` | | [Converting Collections to Arrays](#converting-collections-to-arrays) | `hypervel.unnecessaryEnumerableToArrayCall` | +| [Mutable Dates](#mutable-dates) | `hypervel.mutableDate` | +| [Discarded Dates](#discarded-dates) | `hypervel.discardedDate` | | [Translations](#translations) | `hypervel.missingTranslations`, `hypervel.invalidTranslationLocale` | | [Views](#views) | `hypervel.unusedViews`, `argument.type` for view names | | [Console Input](#console-input) | `hypervel.console.undefinedArgument`, `hypervel.console.undefinedOption` | +| [Native Request APIs](#native-request-apis) | `hypervel.nativeRequestApi` | | [Authentication in Requests](#authentication-in-requests) | `hypervel.noAuthFacadeInRequestScope`, `hypervel.noAuthHelperInRequestScope` | | [Redis Connections](#redis-connections) | `hypervel.redisConnectionBoundMethod` | +| [Borrowed Resources](#borrowed-resources) | `hypervel.borrowedResourceEscape` | | [Cancellation](#cancellation) | `hypervel.cancellation.exactType`, `hypervel.cancellation.precedence` | +| [Swallowed Cancellation](#swallowed-cancellation) | `hypervel.cancellation.swallowed` | +| [Raw Coroutines](#raw-coroutines) | `hypervel.rawCoroutine` | | [Coroutines in Transactions](#coroutines-in-transactions) | `hypervel.coroutineInTransaction` | +| [Managed Transactions](#managed-transactions) | `hypervel.transactionEndedEarly` | | [Sleeping](#sleeping) | `hypervel.rawSleep` | | [Worker State](#worker-state) | `hypervel.workerSharedState`, `hypervel.staticStateMutation`, `hypervel.unboundedWorkerCache` | +| [Restricted Methods](#restricted-methods) | `hypervel.restrictedCall`, `hypervel.restrictedAttribute` | +| [Unsupported Methods](#unsupported-methods) | `hypervel.unsupportedCall` | | [Runtime Configuration](#runtime-configuration) | `hypervel.runtimeConfigMutation` | +| [Socialite Providers](#socialite-providers) | `hypervel.socialiteProperty` | +| [Swoole Tables](#swoole-tables) | `hypervel.swooleTableInProvider` | +| [Binding Cycles](#binding-cycles) | `hypervel.bindingCycle` | +| [Conditional Bindings](#conditional-bindings) | `hypervel.requestDependentBinding` | | [Model Relations](#model-relations) | `hypervel.relationExistence` | | [Model Columns](#model-columns) | `argument.type` for column names, `method.notFound` and `staticMethod.notFound` for dynamic wheres | | [Model Scopes and Accessors](#model-scopes-and-accessors) | `hypervel.legacyModelScope`, `hypervel.noPublicModelScopeMethod`, `hypervel.noPublicModelAccessorMethod` | | [Model Configuration](#model-configuration) | `hypervel.legacyModelProperty` | +| [Model Observers](#model-observers) | `hypervel.observerAttribute` | | [Appended Attributes](#appended-attributes) | `hypervel.modelAppends` | | [Creating Models](#creating-models) | `hypervel.noModelMake` | | [Query Results](#query-results) | `hypervel.noUnnecessaryCollectionCall` | | [Starting Queries](#starting-queries) | `hypervel.noImplicitQueryBuilderCall` | | [Schema Dumps](#schema-dumps) | `hypervel.schemaDump` | +| [Migration Column Types](#migration-column-types) | `hypervel.migrationColumnType` | | [Dispatching Jobs and Events](#dispatching-jobs-and-events) | `hypervel.jobs.noConstructor`, `hypervel.events.noConstructor`, `argument.type` and the other argument identifiers | | [Dispatching Jobs in Transactions](#dispatching-jobs-in-transactions) | `hypervel.dispatchInTransactionAfterCommit` | | [Models in Queued Jobs](#models-in-queued-jobs) | `hypervel.jobSerializesModels` | @@ -693,6 +742,19 @@ The check covers direct calls to `env`, `env_array`, `env_or_fail`, `app_id`, `E Some infrastructure code genuinely needs the process environment, such as code that runs before configuration loads, values passed to child processes, or settings provided by a test runner. Ignore those calls individually, with a reason. You may disable the rule using the `noEnvCallsOutsideOfConfig` parameter. + +### Casting Environment Variables + +The `env` helper converts [reserved values](/docs/{{version}}/configuration#environment-variable-types) such as `true` and `null`, but otherwise reads environment values as strings. Passing a numeric or boolean default does not convert them. Cast these values in your configuration files: + +```php +'port' => (int) env('DB_PORT', 3306), +``` + +The check reports unconverted `env()` and `Env::get()` reads with boolean, integer or float defaults used directly as configuration values, including nested defaults such as `env('CACHE_TTL', env('DEFAULT_TTL', 60))`. Casts and conversions such as `filter_var()` are allowed. Values held in variables are not followed. When an environment variable holds a name or other text, use a string default instead. + +When a setting accepts several types, such as `false` or a URL, ignore the diagnostic with a reason. You may disable the check using the `checkConfigEnvCasts` parameter. + ### Configuration Values @@ -725,6 +787,19 @@ parameters: - modules/*/config ``` + +### Typed Configuration Getters + +When a configuration key always holds a string, integer, float, boolean or array, read it using the matching [typed getter](/docs/{{version}}/configuration#typed-configuration-values). The getter returns that type and throws an exception if the configured value has another type: + +```php +$name = config()->string('app.name'); +``` + +Reading such a key using `config('key')`, `Config::get()` or a repository's `get` method is reported, even when the call supplies a default. Keep using `get()` for values that may be `null` or several types, and for keys your configuration may not define. + +Only your application's configuration repository is checked, as described in [Configuration and Services](#configuration-and-services). The check does not run when you analyze a package, since the applications using it may give its keys other values. You may disable the check using the `checkTypedConfigGetters` parameter. + ### Array Keys @@ -776,6 +851,32 @@ $ids = collect([1, 2, 3])->all(); Keep `toArray()` when the items are, or may be, `Arrayable`, such as models. Collections that override `toArray()`, `all()` or `map()` are not checked. You may disable the rule using the `noUnnecessaryEnumerableToArrayCalls` parameter. + +### Mutable Dates + +Hypervel uses [immutable dates](/docs/{{version}}/helpers#dates) by default. Create dates using the `Date` facade, the `now` and `today` helpers, or `CarbonImmutable`: + +```php +$startsAt = now(); +``` + +The check reports `new Carbon` and static calls that return a mutable date, such as `now`, `parse` and `create`, on `Carbon\Carbon` and `Hypervel\Support\Carbon`. When code needs a mutable date, such as to work with a library that expects one, ignore the diagnostic with a reason. You may disable the check using the `noMutableDates` parameter. + + +### Discarded Dates + +Immutable date methods such as `addDays` and `startOfMonth` return a new date. Assign the result to keep the change: + +```php +$deadline = now(); + +$deadline = $deadline->addDays(7); +``` + +Discarding the result of a Carbon modifier or setter, such as `setTime`, `timezone` or `year(2026)`, is reported. Methods such as `dayOfYear()` read a value when called without an argument and are reported only when they set one, as in `dayOfYear(100)`. + +For a `CarbonImmutable`, the diagnostic says the date is unchanged. For a `CarbonInterface`, including the result of `now()`, it says the change may be lost because the date could be mutable or immutable. Mutable `Carbon` dates change in place and are not reported. You may disable the check using the `checkDiscardedDates` parameter. + ### Translations @@ -811,6 +912,21 @@ if ($this->hasOption('force') && $this->option('force')) { } ``` + +### Native Request APIs + +Use Hypervel's [request](/docs/{{version}}/requests), [response](/docs/{{version}}/responses) and [session](/docs/{{version}}/session) APIs when handling a request. PHP's native request APIs act on process state instead, so calls such as `header()` are reported. Set the header on the response: + +```php +return response($content)->header('X-Frame-Options', 'DENY'); +``` + +The check covers `header`, `header_remove`, `headers_list`, `headers_sent`, `http_response_code`, `setcookie`, `setrawcookie`, `getallheaders`, `apache_request_headers`, `connection_aborted`, `connection_status`, `ignore_user_abort` and every `session_*` function except `session_create_id`. It also reports `filter_input`, `filter_input_array` and `filter_has_var` when they read `INPUT_GET`, `INPUT_POST` or `INPUT_COOKIE`, and `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_REQUEST` and `$_SESSION`. In particular, `$_SESSION` is shared by every request in the worker. + +A generator callback passed to `response()->stream()` stops streaming at the first failed write after the client disconnects. To also stop it during a wait when the connection closes, call `cancelOnDisconnect()`, as described in [Canceling Disconnected Streams](/docs/{{version}}/responses#canceling-disconnected-streams). + +When code deliberately uses one of these functions outside a request, such as a console command that calls `ignore_user_abort`, ignore the diagnostic with a reason. You may disable the check using the `noNativeRequestApis` parameter. + ### Authentication in Requests @@ -832,6 +948,33 @@ Redis::withConnection(function (RedisConnection $connection) { These checks may be disabled using the `checkRedisConnectionBoundMethods` parameter. + +### Borrowed Resources + +Methods such as `Redis::withConnection()` lend a pooled connection to a callback. Finish using the connection inside that callback, and return the result of your work: + +```php +use Hypervel\Redis\RedisConnection; +use Hypervel\Support\Facades\Redis; + +$value = Redis::withConnection(function (RedisConnection $connection) { + return $connection->get('key'); +}); +``` + +Once the callback finishes, another coroutine may borrow the connection. The check reports returning it, storing it outside the callback, or capturing it in work that can outlive the callback. Arrays and closures holding the connection count too. Storage includes properties on objects from outside the callback, by-reference `use` variables and global variables. This includes closures passed to `go()`, `parallel()`, `Coroutine::defer()` and `Timer::after()`. + +The check covers these borrowed resources, including local variables holding them: + +- Redis connections lent by `withConnection()` and their `client()` handles. +- Queues lent by a [pooled SQS connection's](/docs/{{version}}/queues#connection-pools) `withConnection()` and their `getSqs()` clients. +- Drivers, adapters and clients lent by a [pooled disk's](/docs/{{version}}/filesystem#driver-pools) `withDriver()`, `withAdapter()` and `withClient()` methods. +- PDO handles obtained inside `DB::withPinnedSession()`, whose session may return to the pool when the [pin](/docs/{{version}}/database#releasing-and-pinning-connections) ends. + +A generator callback that uses a borrowed resource is reported too. Calling a generator only creates it, so its body runs once it is iterated, after the borrow has ended. Return the results instead of yielding them. + +If a child coroutine must use a borrowed connection, call `wait()` with `waitForChildTermination: true`. This waits for the child to finish even when the caller is canceled. Values passed to other functions are not followed, and neither are variables once they are bound by reference. You may disable the check using the `checkBorrowedResources` parameter. + ### Cancellation @@ -864,7 +1007,7 @@ try { } ``` -Use the same approach instead of `instanceof CanceledException` or storing a directly caught cancellation for later. If the value may be `null`, such as `getPrevious()`, check `instanceof Throwable` first. A `Throwable` catch that only rethrows is not reported. Neither is a `try` block containing only waits that throw cancellation directly, such as channel waits or `Coroutine::join`. +Use the same approach instead of `instanceof CanceledException` or storing a directly caught cancellation for later. If the value may be `null`, such as `getPrevious()`, check `instanceof Throwable` first. A `Throwable` catch that only rethrows is not reported, and neither is one that handles ordinary failures inside `if (! Coroutine::causedByCancellation($exception))` and then rethrows. Neither is a `try` block containing only waits that throw cancellation directly, such as channel waits or `Coroutine::join`. When you keep the first of several failures using `??=`, a later failure carrying cancellation is lost. Use `Coroutine::preferCancellation`, which keeps the first failure unless a later one carries this coroutine's cancellation: @@ -874,6 +1017,52 @@ $failure = Coroutine::preferCancellation($failure, $exception); The check allows `??=` when no earlier failure can exist or the branch excludes cancellation. You may disable these checks using the `checkCancellationHandling` and `checkCancellationPrecedence` parameters. + +### Swallowed Cancellation + +When a coroutine is [canceled](/docs/{{version}}/coroutines#coroutine-cancellation), an operation such as a database query or channel wait can throw a cancellation exception. Treating it as an ordinary failure lets canceled work continue. Rethrow the exception, or check for cancellation before handling other failures: + +```php +use Hypervel\Coroutine\Coroutine; +use Hypervel\Support\Facades\DB; + +try { + $rates = DB::table('rates')->get(); +} catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + $rates = collect(); +} +``` + +A catch that ends by throwing a new exception also propagates the cancellation when it passes the caught exception as the new exception's previous exception, such as `throw new RatesUnavailable('Rates are unavailable.', previous: $exception)`. A `ForeignCancellationException` does not count, since `causedByCancellation` treats it as another coroutine's cancellation. + +The check reports the first catch that receives cancellation, such as a `Throwable` or `Exception` catch, if it swallows cancellation from one of these operations directly inside its `try` block: + +- Database queries through a connection, the `DB` facade, the query and Eloquent builders, models, relationships, an Eloquent collection's `load` methods, or the schema builder. Calls such as `cursor()` and `lazy()` run their queries when their results are iterated, so they count when the `try` block iterates them directly with `foreach` or `each()`. A lazy result held in a variable or passed through other collection methods is not followed. +- Redis commands through the `Redis` facade or `Redis::connection()`, including pipelines and transactions given a callback. +- [HTTP client](/docs/{{version}}/http-client) requests. +- Waits: channel `push` and `pop`, `WaitGroup::wait`, `wait()`, `parallel()`, `Concurrency::run()`, starting a coroutine through `Concurrent` or `WaitConcurrent`, which waits for a free slot, `WaitConcurrent::wait`, `Coroutine::join`, `Coroutine::sleep`, PHP's `sleep` and `usleep` functions, a [`Sleep`](/docs/{{version}}/helpers#sleep) expression whose result is discarded, and `Sleep::then()`. A `Sleep` held in a variable is not followed. + +Cache, filesystem, queue and mail calls are not checked, since their driver is configuration. They can still throw a cancellation, so a broad catch around them needs the same handling. + +Only request, job and operation code is checked, as described in [Worker State](#worker-state). An operation inside a nested `try` whose catch swallows the cancellation is reported at that `try` instead. When a catch propagates the cancellation in a way the analysis cannot follow, such as through a helper method, ignore the diagnostic with a reason. You may disable the check using the `checkCancellationHandling` parameter, which also disables the `CanceledException` checks described in [Cancellation](#cancellation). + + +### Raw Coroutines + +An exception escaping a coroutine started directly through Swoole or Hypervel's engine layer, including cancellation, stops the worker process. Use Hypervel's [coroutine helpers](/docs/{{version}}/coroutines#creating-a-child-coroutine), which [report](/docs/{{version}}/coroutines#reporting-unhandled-exceptions) unhandled exceptions: + +```php +use function Hypervel\Coroutine\go; + +go(fn () => $this->refreshRates()); +``` + +The check covers `Swoole\Coroutine::create()`, `Swoole\Coroutine\go()`, and `Hypervel\Engine\Coroutine::create()` and `execute()`. To copy the parent's [coroutine context](/docs/{{version}}/coroutines#copying-coroutine-context), use `Coroutine::fork()` or the helpers' `copyContext` argument. Keep a direct call only when its callback catches every exception itself, and ignore the diagnostic with a reason. You may disable the check using the `noRawCoroutines` parameter. + ### Coroutines in Transactions @@ -890,6 +1079,24 @@ DB::transaction(function () use ($order) { Start the coroutine from a `DB::afterCommit` callback, or do the work in the transaction's own coroutine. The check covers `go`, `co`, `parallel`, `wait`, `Coroutine::create`, `Coroutine::createOwned`, `Coroutine::fork`, `Coroutine::forkOwned`, `Parallel`, `Concurrent`, `WaitConcurrent` and `Waiter`. It also covers `Concurrency::run` when the selected driver is known to be `coroutine`. You may disable it using the `noCoroutinesInTransactions` parameter. + +### Managed Transactions + +Let the [`transaction`](/docs/{{version}}/database#database-transactions) method manage its transaction. It commits when the closure returns and rolls back when the closure throws an exception: + +```php +DB::transaction(function () use ($order) { + $order->save(); + $order->items()->update(['status' => 'confirmed']); +}); +``` + +Calling `commit()` or `rollBack()` to end that transaction inside the closure is reported, because later work in the closure would run outside it. Balanced calls to `beginTransaction()` and `commit()` or `rollBack()` are allowed for nested transactions. `rollBack(0)` is reported because it also rolls back every enclosing transaction. + +A closure that uses `yield` is reported too. Calling it only creates a generator, so `transaction` commits before any of the closure's work runs. Do the database work in the closure and return the results instead of yielding them. + +The check follows calls on the connection the closure receives, a connection variable it captures, a connection property of `$this`, the `DB` facade, and `DB::connection()` with the same connection name as the `transaction` call. Once the closure begins, commits or rolls back a transaction conditionally, such as inside an `if`, a loop or a `try`, the rest of the closure is not checked. You may disable the check using the `checkManagedTransactions` parameter. + ### Sleeping @@ -928,10 +1135,12 @@ Pass request values to the methods that use them, or keep them in [CoroutineCont - A property of a shared service that keeps the current request, a response, a user, an uploaded file or a model, whether a method assigns it or the constructor receives it. (`hypervel.workerSharedState`) - A shared middleware, controller or job that writes to its own properties while handling a request or running a job. (`hypervel.workerSharedState`) -- A static property written while handling a request or running a job, or one that keeps any of the values above. (`hypervel.staticStateMutation`) +- A static property or global variable written while handling a request or running a job, or one that keeps any of the values above. Global variables are `$GLOBALS` entries, and names a function binds with a `global` statement that isn't inside a condition or loop. The unbounded-cache check does not inspect global arrays. (`hypervel.staticStateMutation`) - An array on a static property or shared service whose keys come from the request or user, such as `$this->visits[$request->ip()]`, since it can grow for as long as the worker runs. Use a cache store with an expiry instead. Keys limited to a small set of values, such as booleans or constants, are not reported, and neither is an array the same method has just reset. When the array is bounded in a way the analysis cannot see, ignore the diagnostic with a reason. (`hypervel.unboundedWorkerCache`) -Request and job checks cover middleware, controller actions, route closures, form requests, and queued classes' `handle`, `__invoke` and `failed` methods, along with the methods they call directly on `$this`. Nested closures are not checked because they may run later. +Request and job checks cover middleware, controller actions, route closures, form requests, and queued classes' `handle`, `__invoke` and `failed` methods. They also cover listeners and [passive observers](/docs/{{version}}/events#passive-observers) for framework request, routing and queue job events, including wildcard ones whose pattern matches those events. Queued listeners and queued Eloquent observers are checked as jobs. A queued listener using `InteractsWithQueue` handles each job on its own copy, so its handler and the helpers it calls may replace its properties and write into the arrays and strings they hold. The copy shares objects such as collections with the shared instance, so changes made through them are still checked. So are inherited methods that a shared instance of the parent class, or of another subclass, also runs. Its `failed`, `shouldQueue`, `uniqueVia` and `debounceVia` methods run on the shared instance, so they and the helpers they call are still checked. Option methods such as `viaQueue`, `backoff` and `uniqueId` run when the event is dispatched, on an instance made without the constructor, so they may set that instance's own properties and are otherwise checked like the event's listeners. A change made through an object the handler has just put in a property is still reported, so ignore a safe one with a reason. + +Listeners for server events such as `OnReceive` and `OnClose`, and Eloquent observers registered using `observe` or `ObservedBy`, are checked as operations. These handlers can run outside HTTP requests and queued jobs. The checks also follow calls to helper methods on `$this`. Nested closures are not checked because they may run later. Shared services are identified from your application's container registrations, resolved classes, handlers and constructor dependencies, following the container's [resolution lifecycles](/docs/{{version}}/container#resolution-lifecycles). A constructor-injected model used as a fresh prototype is allowed. Objects created inside binding closures are not inspected, and job `handle` dependencies are followed only for jobs using `Dispatchable`. @@ -939,6 +1148,48 @@ Writes in methods marked as [boot-time or test-only](#method-attributes), and in Other worker state, such as a lazily created connection or a registry filled while the application boots, is not reported. When a reported write is intended, ignore it with a reason. You may disable these checks using the `checkWorkerSharedState`, `checkStaticStateMutation` and `checkUnboundedWorkerCaches` parameters. + +### Restricted Methods + +Methods marked as [boot-time or test-only](#method-attributes) are intended for those contexts. For example, configure an Eloquent morph map in a service provider's `boot` method, before requests begin: + +```php +use App\Models\Post; +use Hypervel\Database\Eloquent\Relations\Relation; + +public function boot(): void +{ + Relation::morphMap(['post' => Post::class]); +} +``` + +Calling a restricted method from the request, job or operation code described in [Worker State](#worker-state) is reported when the attribute's conditions apply. Call test-only methods from tests, and keep per-request state in [CoroutineContext](/docs/{{version}}/coroutine-context) or a [scoped binding](/docs/{{version}}/container#binding-scoped). + +A `whenShared` restriction applies only to an instance shared by the worker. For example, `Http::globalOptions()` changes the shared HTTP client factory, while a factory you create yourself may be configured independently: + +```php +use Hypervel\Http\Client\Factory; + +$factory = (new Factory)->globalOptions(['timeout' => 5]); +``` + +The analysis recognizes shared instances reached through facades, calls such as `app('router')`, and dependencies the container supplies to controller actions, job handlers and readonly promoted constructor properties. Other receivers are not reported for `whenShared` restrictions. Configuration writes, such as `Config::set()`, are reported as [runtime configuration](#runtime-configuration) changes instead. + +If a restriction depends on an argument, choose either `whenNotDefault` or `whenProvided` and name a parameter with a default value. Use `whenShared` only on instance methods. Invalid combinations are reported as `hypervel.restrictedAttribute`. You may disable these checks using the `checkRestrictedCalls` parameter. + + +### Unsupported Methods + +A call that reaches a method marked [`Unsupported`](#method-attributes) always throws, so it is reported along with the attribute's message: + +```php +// App\Clients\SyncClient::callAsync() is not supported by this implementation and always throws. +// Use call() instead. +$client->callAsync('refresh'); +``` + +Calls through facades are checked too. When a facade forwards calls with its own `__callStatic` method, the calls it forwards to its root are not checked, but its own marked methods still are. The call is judged by the method it reaches, so a call through an interface or base class whose method is not marked is not reported. Neither is a call inside another marked method, since an unsupported method may throw by calling another one. + ### Runtime Configuration @@ -954,9 +1205,71 @@ public function handle(Request $request, Closure $next): Response } ``` -Set configuration during application boot and keep per-request values in [CoroutineContext](/docs/{{version}}/coroutine-context). The check reports writes through the `config` helper, `Config` facade, container-resolved repository and supported method-injected parameters in the [request and job methods](#worker-state) described above. +Set configuration during application boot and keep per-request values in [CoroutineContext](/docs/{{version}}/coroutine-context). The check reports writes through the `config` helper, `Config` facade, container-resolved repository and supported method-injected parameters in the [request, job and operation code](#worker-state) described above. + +Writes through a readonly promoted constructor property are reported when the container builds the class and injects your application's repository. Only writes known to target the shared configuration are reported, so repositories you construct yourself, and other properties, are not checked. You may disable the rule using the `noRuntimeConfigMutation` parameter. + + +### Socialite Providers + +Socialite providers are shared by the worker. Their properties hold defaults, while each request's settings live in [coroutine context](/docs/{{version}}/coroutine-context). In a [custom provider](/docs/{{version}}/socialite#custom-providers), use the provider's methods to read and change the current settings: + +```php +protected function getAuthUrl(?string $state): string +{ + $scopes = $this->getScopes(); + + // ... +} +``` -Only writes known to target the shared configuration are reported. Repositories you construct yourself or store in properties are not checked. You may disable the rule using the `noRuntimeConfigMutation` parameter. +Direct reads and writes of request-specific properties are reported. Use pairs such as `getScopes()` and `setScopes()`, `getRedirectUrl()` and `redirectUrl()`, or `getParameters()` and `with()`. Constructors may set defaults, and a getter override may read them; these uses are not reported. You may disable the check using the `checkSocialiteProviders` parameter. + + +### Swoole Tables + +Create shared Swoole tables from a `Hypervel\Core\Events\BeforeServerStart` listener, before the server starts its workers. Creating a table directly in a service provider's `register` or `boot` method is reported because those methods also run in queue workers and console commands that never use the table. + +The check does not inspect closures, such as singleton factories. Resolve table-creating factories from a `BeforeServerStart` listener too; wrapping construction in a factory does not help if a provider immediately resolves it. You may disable the check using the `checkSwooleTables` parameter. + + +### Binding Cycles + +When a class is already an alias of a container key, bind a closure that constructs the class. Binding the key directly to that class name creates a resolution cycle. For example, `AuthManager` is an alias of `auth`, so use: + +```php +use Hypervel\Auth\AuthManager; + +$this->app->singleton('auth', fn ($app) => new AuthManager($app)); +``` + +The check uses your application's registered aliases and covers `bind`, `singleton` and `scoped` on the application container and `App` facade. The `bindIf`, `singletonIf` and `scopedIf` methods are not checked, since the key may already be bound. You may disable the check using the `checkBindingCycles` parameter. + + +### Conditional Bindings + +[`BindWhen`](/docs/{{version}}/container#bind-when-attribute) conditions must depend on state set during application boot, such as configuration. Once a condition matches, the binding remains for the worker's lifetime. A condition that reads the current request or user would apply that request's choice to later requests: + +```php +use App\Dashboards\AdminDashboard; +use Hypervel\Container\Attributes\BindWhen; + +#[BindWhen( + AdminDashboard::class, + // Reported: this choice depends on the current request. + static function (): bool { + return request()->is('admin/*'); + }, +)] +interface Dashboard +{ + // ... +} +``` + +To choose an implementation for each request, register a [scoped binding](/docs/{{version}}/container#binding-scoped) instead. + +The check detects request reads through the `request` helper, `Request` facade, `RequestContext` and container, along with user lookups through `Auth` or `auth()`. Nested callbacks are inspected too, as are functions and static methods used as first-class callable conditions, such as `Conditions::onAdminPath(...)` or `Auth::check(...)`. You may disable the check using the `checkBindWhenConditions` parameter. ### Model Relations @@ -1041,6 +1354,25 @@ Review subclasses before converting a model. Attributes and properties have diff Keep properties when consumers rely on this behavior, especially on models that applications extend, and ignore the diagnostic with a reason. You may disable the check using `noLegacyModelProperties`. + +### Model Observers + +Register a model's [observers](/docs/{{version}}/eloquent#defining-observers) using the `ObservedBy` attribute on the model: + +```php +use App\Observers\InvoiceObserver; +use Hypervel\Database\Eloquent\Attributes\ObservedBy; +use Hypervel\Database\Eloquent\Model; + +#[ObservedBy([InvoiceObserver::class])] +class Invoice extends Model +{ + // ... +} +``` + +The check reports `observe` calls with observer class names in a service provider's `register` and `boot` methods. Models from installed packages, which you cannot add an attribute to, are not reported, and neither are observer instances, conditional registrations or calls inside closures. Subclasses inherit the observers an attribute registers, so when the observers are meant for the model itself only, keep `observe` and ignore the diagnostic with a reason. You may disable the check using the `checkObserverAttributes` parameter. + ### Appended Attributes @@ -1119,6 +1451,21 @@ database/schema/pgsql-schema.dump:1: Model property types cannot use this schema Until the dump is fixed, the tables it defines are unknown, so their attributes read as `mixed`. Regenerate the dump using the `schema:dump` Artisan command, which writes plain SQL. A dump is only reported when the analyzed code reads a model attribute on its connection. You may stop reading schema dumps using the `disableSchemaScan` parameter. + +### Migration Column Types + +For JSON that the database queries, indexes or validates, use [`jsonb`](/docs/{{version}}/migrations#column-method-jsonb), which uses the appropriate type for each database. PostgreSQL's `json` type does not support the same indexing and comparisons: + +```php +Schema::create('flights', function (Blueprint $table) { + $table->jsonb('options'); +}); +``` + +Use `text` for opaque values that are only read back whole. Prefer a string column with a PHP enum cast over a database `enum`, which requires a migration to add values. Instead of `set`, store values in a `jsonb` column cast using `AsEnumCollection`, or in a related table. + +The `checkMigrationColumnTypes` parameter enables reports for `json`, `enum` and `set` columns. It is disabled by default, since existing migrations should not be edited. + ### Dispatching Jobs and Events diff --git a/src/engine/src/Coroutine.php b/src/engine/src/Coroutine.php index c614bedc6..633a180f9 100644 --- a/src/engine/src/Coroutine.php +++ b/src/engine/src/Coroutine.php @@ -36,6 +36,7 @@ public function __construct(callable $callable) public static function create(callable $callable, mixed ...$data): static { $coroutine = new static($callable); + // @phpstan-ignore hypervel.rawCoroutine (the engine primitive: its callers contain the callback) $coroutine->execute(...$data); return $coroutine; } @@ -47,6 +48,7 @@ public function execute(mixed ...$data): static { // Swoole warns when its coroutine limit is exceeded; expose that native // failure through the typed framework exception instead of two signals. + // @phpstan-ignore hypervel.rawCoroutine (the engine primitive: its callers contain the callback) $id = @SwooleCo::create($this->callable, ...$data); if ($id === false) { diff --git a/src/engine/src/SafeSocket.php b/src/engine/src/SafeSocket.php index 76e97ec9c..6493b3563 100644 --- a/src/engine/src/SafeSocket.php +++ b/src/engine/src/SafeSocket.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Engine\SocketInterface; use Hypervel\Engine\Exceptions\SocketClosedException; use Hypervel\Engine\Exceptions\SocketTimeoutException; +use Hypervel\Support\FailureReporter; use Psr\Log\LoggerInterface; use Swoole\Coroutine\Socket; use Throwable; @@ -156,6 +157,7 @@ protected function loop(): void $started = false; try { + // @phpstan-ignore hypervel.rawCoroutine (the engine package sits below hypervel/coroutine, and the send loop contains every failure, including its logger's) Coroutine::create(function () use (&$started): void { $started = true; @@ -187,7 +189,12 @@ protected function loop(): void if (! $exception instanceof SocketClosedException && ! $exception instanceof SocketTimeoutException ) { - $this->logger?->critical((string) $exception); + // A failing logger must not escape this raw coroutine before the socket closes. + try { + $this->logger?->critical((string) $exception); + } catch (Throwable $loggingFailure) { + FailureReporter::report($loggingFailure); + } } $this->close(); diff --git a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php index 67331bf19..d83cb4370 100644 --- a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php +++ b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php @@ -25,6 +25,7 @@ use Hypervel\Support\Attributes\BootOnly; use Hypervel\Support\Attributes\Unsupported; use Hypervel\Support\Traits\Conditionable; +use League\Flysystem\FilesystemAdapter as FlysystemAdapter; use League\Flysystem\FilesystemOperator; use Psr\Http\Message\StreamInterface; use RuntimeException; @@ -534,6 +535,12 @@ public function getClient(): never /** * Run a callback with borrow-scoped access to the Flysystem driver. + * + * @template TDriver of FilesystemOperator + * @template T + * + * @param Closure(TDriver): T $callback + * @return T */ public function withDriver(Closure $callback): mixed { @@ -570,6 +577,12 @@ public function getOperator(): FilesystemOperator /** * Run a callback with borrow-scoped access to the Flysystem adapter. + * + * @template TAdapter of FlysystemAdapter + * @template T + * + * @param Closure(TAdapter): T $callback + * @return T */ public function withAdapter(Closure $callback): mixed { @@ -578,6 +591,12 @@ public function withAdapter(Closure $callback): mixed /** * Run a callback with borrow-scoped access to the driver client. + * + * @template TClient + * @template T + * + * @param Closure(TClient): T $callback + * @return T */ public function withClient(Closure $callback): mixed { diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 8bae174f5..0214ca688 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -1101,6 +1101,12 @@ public function getAdapter(): FlysystemAdapter /** * Run a callback with the Flysystem driver, as pooled disks do with a borrowed one. + * + * @template TDriver of FilesystemOperator + * @template T + * + * @param Closure(TDriver): T $callback + * @return T */ public function withDriver(Closure $callback): mixed { @@ -1109,6 +1115,12 @@ public function withDriver(Closure $callback): mixed /** * Run a callback with the Flysystem adapter, as pooled disks do with a borrowed one. + * + * @template TAdapter of FlysystemAdapter + * @template T + * + * @param Closure(TAdapter): T $callback + * @return T */ public function withAdapter(Closure $callback): mixed { diff --git a/src/foundation/config/logging.php b/src/foundation/config/logging.php index ae02eeb61..447115ca9 100644 --- a/src/foundation/config/logging.php +++ b/src/foundation/config/logging.php @@ -143,7 +143,7 @@ 'syslog' => [ 'driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), - 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'facility' => env('LOG_SYSLOG_FACILITY', 'user'), 'replace_placeholders' => true, ], diff --git a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php index 9536a0a35..d4c4cb82a 100644 --- a/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php +++ b/src/foundation/src/Bootstrap/LoadEnvironmentVariables.php @@ -82,8 +82,7 @@ protected function writeErrorAndDie(InvalidFileException $e): never $output->writeln('The environment file is invalid!'); $output->writeln($e->getMessage()); - http_response_code(500); - + // Laravel also sets an HTTP 500 status for FPM. Hypervel bootstraps from the CLI before any response exists. exit(1); } } diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index bfc2fb891..3dea711f2 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -95,6 +95,7 @@ use Hypervel\Support\Attributes\BootOnly; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Composer; +use Hypervel\Support\DateFactory; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; use Hypervel\Support\Facades\URL; @@ -151,6 +152,8 @@ public function register(): void $app->basePath() )); + $this->app->singletonIf('date', DateFactory::class); + $this->registerDeferHandler(); $this->registerClock(); $this->registerConsoleSchedule(); diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index 10de901ce..ee2fb2110 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -152,6 +152,7 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce $completed->push(true); }); + // @phpstan-ignore hypervel.rawCoroutine (the test deadline watchdog runs no application code, catches the timeout it throws, and nothing cancels it) Coroutine::create(static function () use ($root, $deadline, $completed, &$timeoutException, &$rootCompleted): void { $current = Coroutine::getCid(); diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 2469da7d0..994fb7efd 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -12,7 +12,6 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Database\CachedConnectionResolver; use Hypervel\Database\Connection; -use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionName; use Hypervel\Database\ConnectionResolver; use Hypervel\Database\Pool\DatabasePool; @@ -35,7 +34,7 @@ class DatabaseConnectionResolver extends ConnectionResolver implements CachedCon /** * Connections for testing environment. * - * @var array + * @var array */ protected static array $connections = []; @@ -86,15 +85,13 @@ public static function resetCachedConnections(): void $exception = null; foreach (static::$connections as $cacheKey => $connection) { - if ($connection instanceof Connection) { - $connection->resetForPool(); - - if (! $connection->isReusable()) { - try { - static::discardCachedConnection($cacheKey); - } catch (Throwable $throwable) { - $exception = Coroutine::preferCancellation($exception, $throwable); - } + $connection->resetForPool(); + + if (! $connection->isReusable()) { + try { + static::discardCachedConnection($cacheKey); + } catch (Throwable $throwable) { + $exception = Coroutine::preferCancellation($exception, $throwable); } } } @@ -137,7 +134,7 @@ protected static function registerDispatcherRebinding(ContainerContract $contain // canonical key avoids the mismatch. $container->rebinding(Dispatcher::class, function ($app, $dispatcher) { foreach (static::$connections as $connection) { - if ($connection instanceof Connection && $dispatcher instanceof Dispatcher) { + if ($dispatcher instanceof Dispatcher) { $connection->setEventDispatcher($dispatcher); } } @@ -149,7 +146,7 @@ protected static function registerDispatcherRebinding(ContainerContract $contain /** * Get an already resolved connection from the cache. */ - public function getResolvedConnection(string $name): ?ConnectionInterface + public function getResolvedConnection(string $name): ?Connection { if ($connection = static::$connections[$name] ?? null) { return $connection; @@ -161,7 +158,7 @@ public function getResolvedConnection(string $name): ?ConnectionInterface /** * Get the connection owned by the active testing mode without borrowing one. */ - public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface + public function getHeldConnection(UnitEnum|string|null $name = null): ?Connection { $name = $this->connectionName($name); @@ -173,7 +170,7 @@ public function getHeldConnection(UnitEnum|string|null $name = null): ?Connectio /** * Get the connections shared across the test's setup, requests and assertions. * - * @return array + * @return array */ public function getRetainedConnections(): array { @@ -193,7 +190,7 @@ public function hasApplicableTransaction(UnitEnum|string|null $name = null): boo $connection = $this->getResolvedConnection($name); - return $connection instanceof Connection + return $connection !== null ? $connection->hasApplicableTransaction() : parent::hasApplicableTransaction($name); } @@ -248,7 +245,7 @@ protected static function discardCachedConnection(string $cacheKey): void * Borrows connections through the pool manager and retains their owning * wrappers until terminal test teardown. */ - public function connection(UnitEnum|string|null $name = null): ConnectionInterface + public function connection(UnitEnum|string|null $name = null): Connection { $connectionName = ConnectionName::parse($this->connectionName($name)); @@ -258,11 +255,11 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa } if ($connection = static::$connections[$connectionName->requested] ?? null) { - if ($connectionName->role !== null && $connection instanceof Connection) { + if ($connectionName->role !== null) { $connection->setReadWriteType($connectionName->role); } - if ($connectionName->isWrite() && $connection instanceof Connection) { + if ($connectionName->isWrite()) { // resetCachedConnections() runs resetForPool() between tests and clears this flag. $connection->useWriteConnectionWhenReading(); } @@ -276,7 +273,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa if ($cacheKey !== $connectionName->requested && $connection = static::$connections[$cacheKey] ?? null ) { - if ($connectionName->isWrite() && $connection instanceof Connection) { + if ($connectionName->isWrite()) { $connection->useWriteConnectionWhenReading(); } diff --git a/src/foundation/src/Testing/DatabaseTransactions.php b/src/foundation/src/Testing/DatabaseTransactions.php index c3e1fbc71..1674a3a31 100644 --- a/src/foundation/src/Testing/DatabaseTransactions.php +++ b/src/foundation/src/Testing/DatabaseTransactions.php @@ -4,7 +4,6 @@ namespace Hypervel\Foundation\Testing; -use Hypervel\Database\Connection as DatabaseConnection; use Hypervel\Foundation\Testing\Concerns\InteractsWithParallelDatabase; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; @@ -90,9 +89,7 @@ protected function rollbackDatabaseTransactionWork(): void $connection->unsetEventDispatcher(); try { - if ($connection instanceof DatabaseConnection) { - $connection->forgetRecordModificationState(); - } + $connection->forgetRecordModificationState(); // Laravel disconnects after rolling back one level. Hypervel keeps the connection // open, so roll back every level, including transactions the test left open. diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index 573bbeb61..d31b587c4 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -6,7 +6,6 @@ use Hypervel\Contracts\Console\Kernel; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\Connection as DatabaseConnection; use Hypervel\Database\PdoConnection; use Hypervel\Database\SQLiteDatabase; use Hypervel\Foundation\Testing\Concerns\InteractsWithParallelDatabase; @@ -304,10 +303,7 @@ protected function rollbackDatabaseTransactionWork(): void RefreshDatabaseState::$migrated = false; } - if ($connection instanceof DatabaseConnection) { - $connection->forgetRecordModificationState(); - } - + $connection->forgetRecordModificationState(); $connection->rollBack(0); } finally { if ($dispatcher !== null) { diff --git a/src/grpc/src/Client/Connection.php b/src/grpc/src/Client/Connection.php index 20bd21438..57c67d2e2 100644 --- a/src/grpc/src/Client/Connection.php +++ b/src/grpc/src/Client/Connection.php @@ -20,6 +20,7 @@ use Hypervel\Grpc\Protocol\Deadline; use Hypervel\Grpc\Status; use Hypervel\Grpc\StatusCode; +use Hypervel\Support\FailureReporter; use LogicException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -457,6 +458,7 @@ private function startReceiver(): void $this->receiving = true; try { + // @phpstan-ignore hypervel.rawCoroutine (receive() contains cancellation and receiver failures as the terminal owner) Coroutine::create(function (): void { $this->receiverCoroutineId = Coroutine::id(); $this->receive(); @@ -500,14 +502,10 @@ private function receive(): void $this->auditStreams($client); } catch (CanceledException $exception) { if (! $this->isClosed()) { - try { - $this->failConnection($this->connectionException( - new ForeignCancellationException($exception->getMessage(), previous: $exception), - 'The gRPC response receiver was canceled.', - )); - } catch (CanceledException) { - // This raw receiver is the terminal owner of cancellation. - } + $this->failConnection($this->connectionException( + new ForeignCancellationException($exception->getMessage(), previous: $exception), + 'The gRPC response receiver was canceled.', + )); } return; @@ -536,8 +534,10 @@ private function receive(): void return; } } - } catch (CanceledException) { - // This raw receiver is the terminal owner of cancellation. + } catch (Throwable $throwable) { + // This raw receiver is the terminal owner: cancellation ends it, and a cleanup failure, + // such as a native close failure carrying cancellation, is reported. + FailureReporter::report($throwable); } finally { // This is the authoritative signal that the connection no longer owns a receiver. $this->receiverCoroutineId = null; @@ -822,8 +822,9 @@ private function terminateAfterCancellation( new ForeignCancellationException($cancellation->getMessage(), previous: $cancellation), $fallback, )); - } catch (CanceledException) { + } catch (Throwable $throwable) { // The caller's original cancellation remains the primary outcome. + FailureReporter::report($throwable); } throw $cancellation; diff --git a/src/horizon/src/Console/WorkCommand.php b/src/horizon/src/Console/WorkCommand.php index 57bf19b94..a899944e7 100644 --- a/src/horizon/src/Console/WorkCommand.php +++ b/src/horizon/src/Console/WorkCommand.php @@ -45,6 +45,7 @@ class WorkCommand extends BaseWorkCommand public function handle(): ?int { if (config()->boolean('horizon.fast_termination')) { + // @phpstan-ignore hypervel.nativeRequestApi (In the CLI it stops the worker dying on its next output after its terminal goes away.) ignore_user_abort(true); } diff --git a/src/phpstan/README.md b/src/phpstan/README.md index 8d7fddf10..760b20301 100644 --- a/src/phpstan/README.md +++ b/src/phpstan/README.md @@ -14,6 +14,7 @@ Documentation: https://hypervel.org/docs/static-analysis - The request-scope auth rules offer no automatic fix, because the request's user resolver can return a different user than the `Auth` facade or `auth()` helper. - Collections keep Laravel's covariant value type, so a `Collection` is accepted where a `Collection` is expected. Upstream's stubs make it invariant. With bleeding edge, PHPStan does not widen a covariant value type it has already inferred, so a call such as `collect([1, 2, 3])->push(4)` is reported. See [Collection Types](https://hypervel.org/docs/static-analysis#collection-types). - `now()`, `today()` and the `Date` facade return `CarbonInterface`, as declared, rather than the date class configured when analysis runs. Code that needs a specific class should convert the value, for example with `toImmutable()`. +- Configuration value types apply to reads through the `config()` helper, the `Config` facade and application-container calls such as `app('config')`. A repository received as a parameter or property, or created with `new Repository($items)`, keeps its declared return types. Upstream applies the application's configuration types to `get()` on any `Repository` instance. See [Configuration and Services](https://hypervel.org/docs/static-analysis#configuration-and-services). - The `generalizeEnvReturnType` and `redisDriver` settings are omitted. An `env()` default does not decide the type of the value it reads, and Redis types always describe Hypervel's phpredis connection. - Request input getters return the types the input can hold. A keyed `query()`, `post()` or `cookie()` value can be a string, number, boolean, array or `Stringable`, a keyed `server()` or `route()` value is `mixed`, and `header()` can return null even with a default. Whole query, post, cookie and file arrays have `array-key` keys, because a query string or JSON list body can use integer keys. Upstream's narrower types do not cover these values. `FormRequest::validated()` and `safe($keys)` return `array` for the same reason. - `Storage::disk()` and `FilesystemManager::disk()` are typed per disk from the booted configuration, such as `ClientPooledFilesystem` for a pooled S3 disk, instead of always `FilesystemAdapter`. Calls the `Storage` facade forwards to the default disk use that disk's methods. `Storage::cloud()` is not supported. diff --git a/src/phpstan/extension.neon b/src/phpstan/extension.neon index f4678c3ba..7c088285c 100644 --- a/src/phpstan/extension.neon +++ b/src/phpstan/extension.neon @@ -49,7 +49,23 @@ parameters: checkStaticStateMutation: true checkUnboundedWorkerCaches: true noRuntimeConfigMutation: true + checkRestrictedCalls: true noCoroutinesInTransactions: true + checkBorrowedResources: true + checkManagedTransactions: true + noNativeRequestApis: true + checkDiscardedDates: true + noMutableDates: true + checkConfigEnvCasts: true + checkTypedConfigGetters: true + checkObserverAttributes: true + # Off by default: existing migrations should not be edited. + checkMigrationColumnTypes: false + checkBindingCycles: true + noRawCoroutines: true + checkSocialiteProviders: true + checkSwooleTables: true + checkBindWhenConditions: true noRawSleepCalls: true noLegacyModelScopes: true noLegacyModelProperties: true @@ -99,7 +115,22 @@ parametersSchema: checkStaticStateMutation: bool() checkUnboundedWorkerCaches: bool() noRuntimeConfigMutation: bool() + checkRestrictedCalls: bool() noCoroutinesInTransactions: bool() + checkBorrowedResources: bool() + checkManagedTransactions: bool() + noNativeRequestApis: bool() + checkDiscardedDates: bool() + noMutableDates: bool() + checkConfigEnvCasts: bool() + checkTypedConfigGetters: bool() + checkObserverAttributes: bool() + checkMigrationColumnTypes: bool() + checkBindingCycles: bool() + noRawCoroutines: bool() + checkSocialiteProviders: bool() + checkSwooleTables: bool() + checkBindWhenConditions: bool() noRawSleepCalls: bool() noLegacyModelScopes: bool() noLegacyModelProperties: bool() diff --git a/src/phpstan/src/Collectors/RestrictedCallCollector.php b/src/phpstan/src/Collectors/RestrictedCallCollector.php new file mode 100644 index 000000000..66c396988 --- /dev/null +++ b/src/phpstan/src/Collectors/RestrictedCallCollector.php @@ -0,0 +1,270 @@ +}|array{parameter: string}|array{property: array{string, string}} + * @phpstan-type RestrictedCall array{line: int, method: string, attribute: string, condition: null|array{string, string}, may: bool, whenShared: bool, receiver: null|Receiver, function: FunctionFacts} + * + * @implements Collector> + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkRestrictedCalls%')] +class RestrictedCallCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private WorkerStateHelper $helper, + private FacadeHelper $facadeHelper, + ) { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Collect each restricted method the call runs whose restriction may apply. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + // PHPStan also analyzes a nullsafe call as a method call, and a first-class callable as a separate node. + if (! ($node instanceof MethodCall || $node instanceof StaticCall) || ! $node->name instanceof Identifier) { + return null; + } + + $calls = []; + $function = null; + + foreach ($this->facadeHelper->getCalledMethods($node, $node->name->toString(), $scope) as $method) { + $attribute = $this->helper->getLifetimeAttribute($method); + $declaration = $method->getDeclaringClass()->getName() . '::' . $method->getName(); + + if ($attribute === null || isset($calls[$declaration])) { + continue; + } + + // Code that is itself boot-time or test-only may call restricted methods, and + // noRuntimeConfigMutation owns configuration writes. + $function ??= $this->helper->getFunctionFacts($scope, $node); + + if ($function === null || $this->isConfigWrite($node, $scope)) { + return null; + } + + // Exported nodes keep the marker's argument expressions, not the values of constants they read. + $this->trackFiles(SourceFiles::forAttributeArguments($this->reflectionProvider, $method->getDeclaringClass(), $attribute->getName(), $method->getName()), $scope); + + $arguments = $attribute->getArgumentTypes(); + $condition = $this->getCondition($arguments['whenNotDefault'] ?? null, $arguments['whenProvided'] ?? null); + $matches = $condition === null ? TrinaryLogic::createYes() : $this->matchesCondition($node, $method, $condition, $scope); + $whenShared = ($arguments['whenShared'] ?? null)?->isTrue()->yes() ?? false; + + if ($matches->no()) { + continue; + } + + $receiver = $whenShared ? $this->getReceiver($node, $scope) : null; + + if ($whenShared && $receiver === null) { + continue; + } + + $calls[$declaration] = [ + 'line' => $node->getStartLine(), + 'method' => $declaration, + 'attribute' => $attribute->getName(), + 'condition' => $condition, + 'may' => $matches->maybe(), + 'whenShared' => $whenShared, + 'receiver' => $receiver, + 'function' => $function, + ]; + } + + return $calls === [] ? null : array_values($calls); + } + + /** + * Determine whether the call changes configuration, as noRuntimeConfigMutation recognizes it. + */ + private function isConfigWrite(MethodCall|StaticCall $call, Scope $scope): bool + { + return $this->helper->isConfigWrite($call, $scope) + && (! $call instanceof MethodCall || $this->helper->isConfigRepositoryType($scope->getType($call->var))); + } + + /** + * Get the marker's parameter condition, as its argument and the parameter it names. + * + * @return null|array{string, string} + */ + private function getCondition(?Type $whenNotDefault, ?Type $whenProvided): ?array + { + foreach (['whenNotDefault' => $whenNotDefault, 'whenProvided' => $whenProvided] as $argument => $type) { + $names = $type?->getConstantStrings() ?? []; + + if (count($names) === 1) { + return [$argument, $names[0]->getValue()]; + } + } + + return null; + } + + /** + * Determine whether the call meets the marker's parameter condition. + * + * An unpacked argument may bind the parameter unless a named argument or an earlier positional + * one does, so it leaves the binding unknown. + * + * @param array{string, string} $condition + * @param DependencyTracker&Scope $scope + */ + private function matchesCondition(MethodCall|StaticCall $call, ExtendedMethodReflection $method, array $condition, Scope $scope): TrinaryLogic + { + [$kind, $name] = $condition; + $parameters = $method->getOnlyVariant()->getParameters(); + $position = array_find_key($parameters, static fn (ParameterReflection $parameter): bool => $parameter->getName() === $name); + + // RestrictedAttributeRule reports a marker naming a missing parameter. + if ($position === null) { + return TrinaryLogic::createNo(); + } + + $argument = $call->getArg($name, $position); + + if ($argument === null) { + return array_any($call->getArgs(), static fn (Arg $argument): bool => $argument->unpack) + ? TrinaryLogic::createMaybe() + : TrinaryLogic::createNo(); + } + + $default = $parameters[$position]->getDefaultValue(); + + if ($kind === 'whenProvided' || $default === null) { + return TrinaryLogic::createYes(); + } + + // Exported nodes record only that a default exists, so a changed default would not reanalyze the caller. + $this->trackFiles(SourceFiles::forParameterDefault($this->reflectionProvider, $method->getDeclaringClass(), $method->getName(), $name), $scope); + + $type = $scope->getType($argument->value); + $isDefault = $default->isSuperTypeOf($type); + + return match (true) { + $isDefault->no() => TrinaryLogic::createYes(), + $isDefault->yes() && $type->isConstantValue()->yes() => TrinaryLogic::createNo(), + default => TrinaryLogic::createMaybe(), + }; + } + + /** + * Make the call's file depend on the source files, so it is analyzed again when they change. + * + * @param list $files + * @param DependencyTracker&Scope $scope + */ + private function trackFiles(array $files, Scope $scope): void + { + foreach ($files as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + } + + /** + * Get the evidence that the call's receiver may be the instance the container shares for the + * worker, which the rule checks against the booted lifetimes, or null when there is none. + * + * The evidence is a facade's accessor or a literal resolution from the application container, an + * entry method's parameter the container may inject, or a readonly property its constructor promotes. + * + * @param DependencyTracker&Scope $scope + * + * @return null|Receiver + */ + private function getReceiver(MethodCall|StaticCall $call, Scope $scope): ?array + { + if ($call instanceof StaticCall) { + $name = $call->class instanceof Name ? $scope->resolveName($call->class) : null; + $class = $name !== null && $this->reflectionProvider->hasClass($name) ? $this->reflectionProvider->getClass($name) : null; + $accessor = $class !== null && $class->is(Facade::class) ? $this->facadeHelper->getAccessor($class, $scope) : null; + + return $accessor === null ? null : ['abstracts' => [$accessor]]; + } + + $receiver = $call->var; + + if ($receiver instanceof CallLike) { + $abstracts = $this->helper->getApplicationResolvedAbstracts($receiver, $scope); + + return $abstracts === null ? null : ['abstracts' => $abstracts]; + } + + // A closure's own parameters are never supplied by the container. + if ($receiver instanceof Variable && is_string($receiver->name) && ! $scope->isInAnonymousFunction()) { + $parameter = array_find( + $this->helper->getParameters($scope), + static fn (ParameterReflection $parameter): bool => $parameter->getName() === $receiver->name, + ); + + return $parameter === null ? null : ['parameter' => $parameter->getName()]; + } + + $property = $this->helper->getPromotedProperty($receiver, $scope); + + return $property === null ? null : ['property' => $property]; + } +} diff --git a/src/phpstan/src/Collectors/SwallowedCancellationCollector.php b/src/phpstan/src/Collectors/SwallowedCancellationCollector.php new file mode 100644 index 000000000..dcac1a833 --- /dev/null +++ b/src/phpstan/src/Collectors/SwallowedCancellationCollector.php @@ -0,0 +1,939 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkCancellationHandling%')] +class SwallowedCancellationCollector implements Collector +{ + private const string DATABASE = 'a database query'; + + private const string REDIS = 'a Redis command'; + + private const string HTTP = 'an HTTP request'; + + private const string WAIT = 'a wait'; + + /** + * The functions that wait, so a cancellation can reach them while they run. + */ + private const array FUNCTIONS = [ + 'sleep' => true, + 'usleep' => true, + 'hypervel\coroutine\parallel' => true, + 'hypervel\coroutine\wait' => true, + ]; + + /** + * The lowercase connection methods that run a query. + */ + private const array CONNECTION_METHODS = [ + 'select' => true, 'selectone' => true, 'selectfromwriteconnection' => true, 'selectresultsets' => true, + 'scalar' => true, 'insert' => true, 'update' => true, 'delete' => true, 'statement' => true, + 'affectingstatement' => true, 'unprepared' => true, 'threadcount' => true, 'transaction' => true, + 'begintransaction' => true, 'commit' => true, 'rollback' => true, + ]; + + /** + * The lowercase query builder methods that run a query. + */ + private const array QUERY_METHODS = [ + 'get' => true, 'first' => true, 'firstorfail' => true, 'sole' => true, 'find' => true, 'findor' => true, + 'value' => true, 'rawvalue' => true, 'solevalue' => true, 'pluck' => true, 'pluckwithcolumn' => true, + 'implode' => true, 'count' => true, 'min' => true, 'max' => true, 'sum' => true, 'avg' => true, 'average' => true, + 'aggregate' => true, 'numericaggregate' => true, 'exists' => true, 'existsor' => true, 'doesntexist' => true, + 'doesntexistor' => true, 'getcountforpagination' => true, 'explain' => true, 'insert' => true, + 'insertgetid' => true, 'insertorignore' => true, 'insertorignorereturning' => true, 'insertusing' => true, + 'insertorignoreusing' => true, 'update' => true, 'updatefrom' => true, 'updateorinsert' => true, + 'upsert' => true, 'delete' => true, 'truncate' => true, 'increment' => true, 'incrementeach' => true, + 'decrement' => true, 'decrementeach' => true, 'paginate' => true, 'simplepaginate' => true, + 'cursorpaginate' => true, 'chunk' => true, 'chunkmap' => true, 'chunkbyid' => true, 'chunkbyiddesc' => true, + 'orderedchunkbyid' => true, 'each' => true, 'eachbyid' => true, + ]; + + /** + * The lowercase Eloquent builder methods beyond the query builder's that run a query. + */ + private const array ELOQUENT_METHODS = [ + 'firstwhere' => true, 'firstor' => true, 'findsole' => true, 'findorfail' => true, 'findornew' => true, + 'findmany' => true, 'valueorfail' => true, 'getmodels' => true, 'fromquery' => true, 'fillandinsert' => true, + 'fillandinsertorignore' => true, 'fillandinsertgetid' => true, 'create' => true, 'createquietly' => true, + 'forcecreate' => true, 'forcecreatequietly' => true, 'firstorcreate' => true, 'firstornew' => true, + 'updateorcreate' => true, 'createorfirst' => true, 'incrementorcreate' => true, 'touch' => true, + 'forcedelete' => true, + ]; + + /** + * The lowercase model methods that run a query, including the protected increments its __call() runs. + */ + private const array MODEL_METHODS = [ + 'all' => true, 'destroy' => true, 'forcedestroy' => true, 'save' => true, 'savequietly' => true, + 'saveorfail' => true, 'saveorignore' => true, 'update' => true, 'updatequietly' => true, 'updateorfail' => true, + 'push' => true, 'pushquietly' => true, 'delete' => true, 'deletequietly' => true, 'deleteorfail' => true, + 'forcedelete' => true, 'refresh' => true, 'refreshforupdate' => true, 'fresh' => true, 'touch' => true, + 'touchquietly' => true, 'increment' => true, 'incrementquietly' => true, 'incrementeach' => true, + 'incrementeachquietly' => true, 'decrement' => true, 'decrementquietly' => true, 'decrementeach' => true, + 'decrementeachquietly' => true, 'load' => true, 'loadmissing' => true, 'loadaggregate' => true, + 'loadcount' => true, 'loadmax' => true, 'loadmin' => true, 'loadsum' => true, 'loadavg' => true, + 'loadexists' => true, 'loadmorph' => true, 'loadmorphaggregate' => true, 'loadmorphcount' => true, + 'loadmorphmax' => true, 'loadmorphmin' => true, 'loadmorphsum' => true, 'loadmorphavg' => true, + ]; + + /** + * The lowercase Eloquent collection methods that load from the database. + */ + private const array COLLECTION_METHODS = [ + 'load' => true, 'loadmissing' => true, 'loadmissingrelationshipchain' => true, 'loadaggregate' => true, + 'loadcount' => true, 'loadmax' => true, 'loadmin' => true, 'loadsum' => true, 'loadavg' => true, + 'loadexists' => true, 'loadmorph' => true, 'loadmorphcount' => true, 'fresh' => true, + ]; + + /** + * The lowercase relation methods that find or create related models, on the relations that declare them. + */ + private const array RELATION_CREATE_METHODS = [ + 'findornew' => true, 'firstornew' => true, 'firstorcreate' => true, 'createorfirst' => true, + 'restoreorcreate' => true, 'createorrestore' => true, 'updateorcreate' => true, 'incrementorcreate' => true, + ]; + + /** + * The lowercase relation methods that save related models, on the relations that declare them. + */ + private const array RELATION_SAVE_METHODS = [ + 'save' => true, 'savequietly' => true, 'savemany' => true, 'savemanyquietly' => true, 'create' => true, + 'createquietly' => true, 'createmany' => true, 'createmanyquietly' => true, 'forcecreate' => true, + 'forcecreatequietly' => true, + ]; + + /** + * The lowercase relation methods that query related models, on the relations that declare them. + */ + private const array RELATION_QUERY_METHODS = [ + 'find' => true, 'findsole' => true, 'findmany' => true, 'findorfail' => true, 'findor' => true, + 'firstwhere' => true, 'first' => true, 'firstorfail' => true, 'firstor' => true, 'get' => true, + 'paginate' => true, 'simplepaginate' => true, 'cursorpaginate' => true, 'chunk' => true, 'chunkmap' => true, + 'chunkbyid' => true, 'chunkbyiddesc' => true, 'each' => true, 'eachbyid' => true, 'touch' => true, + 'allrelatedids' => true, + ]; + + /** + * The lowercase schema builder methods that run a statement or read the schema. The base builder's + * dropAllTables(), dropAllViews() and dropAllTypes() only throw, so only driver overrides count. + */ + private const array SCHEMA_METHODS = [ + 'createdatabase' => true, 'dropdatabaseifexists' => true, 'getschemas' => true, 'hastable' => true, + 'hasview' => true, 'gettables' => true, 'gettablelisting' => true, 'getviews' => true, 'gettypes' => true, + 'hascolumn' => true, 'hascolumns' => true, 'whentablehascolumn' => true, 'whentabledoesnthavecolumn' => true, + 'whentablehasindex' => true, 'whentabledoesnthaveindex' => true, 'getcolumntype' => true, + 'getcolumnlisting' => true, 'getcolumn' => true, 'getcolumns' => true, 'getindexes' => true, + 'getindexlisting' => true, 'hasindex' => true, 'hasforeignkey' => true, 'getforeignkeys' => true, + 'getpartitions' => true, 'table' => true, 'create' => true, 'createrangepartition' => true, + 'createmigrationrepositorytable' => true, 'drop' => true, 'dropifexists' => true, 'dropcolumns' => true, + 'truncatetables' => true, 'rename' => true, 'enableforeignkeyconstraints' => true, 'disableforeignkeyconstraints' => true, + 'withoutforeignkeyconstraints' => true, 'ensureextensionexists' => true, 'ensurevectorextensionexists' => true, + 'executeblueprint' => true, + ]; + + /** + * The lowercase pivot table methods of a many-to-many relation that write the pivot table. + */ + private const array PIVOT_METHODS = [ + 'attach' => true, 'attachorfail' => true, 'detach' => true, 'detachorfail' => true, 'sync' => true, + 'syncorfail' => true, 'syncwithoutdetaching' => true, 'syncwithoutdetachingorfail' => true, + 'syncwithpivotvalues' => true, 'syncwithpivotvaluesorfail' => true, 'toggle' => true, 'toggleorfail' => true, + 'updateexistingpivot' => true, 'updateexistingpivotorfail' => true, + ]; + + /** + * The lowercase HTTP client methods that send a request. + */ + private const array HTTP_METHODS = [ + 'send' => true, 'get' => true, 'post' => true, 'put' => true, 'patch' => true, 'delete' => true, 'head' => true, + ]; + + /** + * The operation each class's or trait's own declarations run, with the lowercase methods that run it. + * An override is judged by its own declaration, so its class has no entry. + */ + private const array OPERATIONS = [ + ConnectionInterface::class => [self::DATABASE, self::CONNECTION_METHODS], + Connection::class => [self::DATABASE, self::CONNECTION_METHODS], + PdoConnection::class => [self::DATABASE, self::CONNECTION_METHODS], + DatabaseManager::class => [self::DATABASE, self::CONNECTION_METHODS], + DB::class => [self::DATABASE, self::CONNECTION_METHODS], + QueryBuilder::class => [self::DATABASE, self::QUERY_METHODS], + EloquentBuilder::class => [self::DATABASE, self::QUERY_METHODS + self::ELOQUENT_METHODS], + Model::class => [self::DATABASE, self::MODEL_METHODS], + SoftDeletes::class => [self::DATABASE, [ + 'restore' => true, 'restorequietly' => true, 'forcedelete' => true, 'forcedeletequietly' => true, + 'forcedestroy' => true, + ]], + // The builder macros SoftDeletingScope adds, which BuilderMacroResolver resolves to this interface. + SoftDeletingMacros::class => [self::DATABASE, ['restore' => true, 'restoreorcreate' => true, 'createorrestore' => true]], + Prunable::class => [self::DATABASE, ['prune' => true, 'pruneall' => true]], + MassPrunable::class => [self::DATABASE, ['pruneall' => true]], + EloquentCollection::class => [self::DATABASE, self::COLLECTION_METHODS], + Relation::class => [self::DATABASE, [ + 'get' => true, 'geteager' => true, 'sole' => true, 'touch' => true, 'rawupdate' => true, + ]], + HasOneOrMany::class => [self::DATABASE, self::RELATION_CREATE_METHODS + self::RELATION_SAVE_METHODS + [ + 'forcecreatemany' => true, 'forcecreatemanyquietly' => true, 'upsert' => true, + ]], + HasOne::class => [self::DATABASE, ['getresults' => true]], + HasMany::class => [self::DATABASE, ['getresults' => true]], + MorphOneOrMany::class => [self::DATABASE, ['forcecreate' => true, 'upsert' => true]], + MorphOne::class => [self::DATABASE, ['getresults' => true]], + MorphMany::class => [self::DATABASE, ['getresults' => true, 'forcecreate' => true]], + BelongsTo::class => [self::DATABASE, ['getresults' => true, 'touch' => true]], + MorphTo::class => [self::DATABASE, ['geteager' => true, 'touch' => true]], + BelongsToMany::class => [self::DATABASE, self::RELATION_CREATE_METHODS + self::RELATION_SAVE_METHODS + + self::RELATION_QUERY_METHODS + self::PIVOT_METHODS + ['getresults' => true, 'orderedchunkbyid' => true]], + HasOneOrManyThrough::class => [self::DATABASE, self::RELATION_CREATE_METHODS + self::RELATION_QUERY_METHODS], + HasOneThrough::class => [self::DATABASE, ['getresults' => true]], + HasManyThrough::class => [self::DATABASE, ['getresults' => true]], + Pivot::class => [self::DATABASE, ['delete' => true]], + MorphPivot::class => [self::DATABASE, ['delete' => true]], + SchemaBuilder::class => [self::DATABASE, self::SCHEMA_METHODS], + // The facade runs the configured driver's builder, whose drop-all methods run statements. + Schema::class => [self::DATABASE, self::SCHEMA_METHODS + [ + 'dropalltables' => true, 'dropallviews' => true, 'dropalltypes' => true, + ]], + MySqlBuilder::class => [self::DATABASE, ['dropalltables' => true, 'dropallviews' => true]], + PostgresBuilder::class => [self::DATABASE, [ + 'executeblueprint' => true, 'hasview' => true, 'dropalltables' => true, 'dropallviews' => true, + 'dropalltypes' => true, 'getcurrentschemaname' => true, + ]], + // Its database creation and removal work on the database file, not through the connection. + SQLiteBuilder::class => [self::DATABASE, [ + 'executeblueprint' => true, 'gettables' => true, 'getviews' => true, 'getcolumns' => true, + 'getcolumnsforschemastate' => true, 'getindexesforschemastate' => true, + 'getforeignkeysforschemastate' => true, 'dropalltables' => true, 'dropallviews' => true, 'pragma' => true, + 'enableforeignkeyconstraints' => true, 'disableforeignkeyconstraints' => true, + ]], + PendingRequest::class => [self::HTTP, self::HTTP_METHODS], + Http::class => [self::HTTP, self::HTTP_METHODS], + SwooleChannel::class => [self::WAIT, ['push' => true, 'pop' => true]], + EngineChannel::class => [self::WAIT, ['push' => true, 'pop' => true]], + // The contract documents both as waiting. + ChannelInterface::class => [self::WAIT, ['push' => true, 'pop' => true]], + SwooleWaitGroup::class => [self::WAIT, ['wait' => true]], + WaitGroup::class => [self::WAIT, ['wait' => true]], + Waiter::class => [self::WAIT, ['wait' => true]], + Parallel::class => [self::WAIT, ['wait' => true]], + // Starting a coroutine waits for a free slot. + Concurrent::class => [self::WAIT, ['create' => true, 'fork' => true, 'waitforavailableslot' => true]], + WaitConcurrent::class => [self::WAIT, ['create' => true, 'fork' => true, 'wait' => true]], + Concurrency::class => [self::WAIT, ['run' => true]], + SwooleCoroutine::class => [self::WAIT, ['join' => true, 'sleep' => true]], + Coroutine::class => [self::WAIT, ['join' => true, 'sleep' => true]], + EngineCoroutine::class => [self::WAIT, ['join' => true]], + Sleep::class => [self::WAIT, ['then' => true]], + ]; + + /** + * The lowercase query builder methods that return a lazy query. + */ + private const array LAZY_METHODS = ['cursor' => true, 'lazy' => true, 'lazybyid' => true, 'lazybyiddesc' => true]; + + /** + * The lowercase methods returning a lazy query, which runs its queries while it's iterated, on the classes that + * declare them. + */ + private const array LAZY_QUERIES = [ + ConnectionInterface::class => ['cursor' => true], + Connection::class => ['cursor' => true], + PdoConnection::class => ['cursor' => true], + DB::class => ['cursor' => true], + QueryBuilder::class => self::LAZY_METHODS, + EloquentBuilder::class => self::LAZY_METHODS, + BelongsToMany::class => self::LAZY_METHODS, + HasOneOrManyThrough::class => self::LAZY_METHODS, + ]; + + /** + * The lowercase static methods that create a sleep. + */ + private const array SLEEP_FACTORIES = ['for' => true, 'until' => true, 'usleep' => true, 'sleep' => true]; + + /** + * The classes whose declarations run Redis commands. + */ + private const array REDIS_CLASSES = [RedisProxy::class, RedisConnection::class, Redis::class]; + + /** + * The lowercase Redis methods, beyond the commands, that run a command while they're called. + */ + private const array REDIS_METHODS = [ + 'command' => true, 'withconnection' => true, 'withpinnedconnection' => true, + 'scan' => true, 'hscan' => true, 'sscan' => true, 'zscan' => true, + ]; + + /** + * Create a new collector instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private WorkerStateHelper $helper, + private CoroutineRuleHelper $coroutineRuleHelper, + private ConstructorParser $constructorParser, + ) { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + return TryCatch::class; + } + + /** + * Collect the try when the catch receiving cancellation swallows it and the block can deliver one. + * + * @param TryCatch $node + * @param DependencyTracker&Scope $scope + * + * @return null|SwallowedCancellation + */ + public function processNode(Node $node, Scope $scope): ?array + { + $catch = $this->findCancellationCatch($node, $scope); + + if ($catch === null || $this->propagates($catch, $scope)) { + return null; + } + + // ExactCancellationCatchRule reports an exact catch that keeps the cancellation apart from a later broad catch. + foreach ($this->coroutineRuleHelper->findExactCancellationMisses($node, $scope) as [$exactCatch]) { + if ($exactCatch === $catch) { + return null; + } + } + + $operation = $this->findOperation($node->stmts, $scope); + $function = $operation === null ? null : $this->helper->getFunctionFacts($scope, $node); + + return $operation === null || $function === null + ? null + : ['line' => $catch->getStartLine(), 'operation' => $operation, 'function' => $function]; + } + + /** + * Find the first catch that receives CanceledException. Later catches never receive it. + */ + private function findCancellationCatch(TryCatch $node, Scope $scope): ?Catch_ + { + $cancellation = $this->reflectionProvider->getClass(CanceledException::class); + + foreach ($node->catches as $catch) { + foreach ($catch->types as $type) { + if ($cancellation->is($scope->resolveName($type))) { + return $catch; + } + } + } + + return null; + } + + /** + * Determine whether the catch propagates the cancellation: the statements a cancellation runs end by + * rethrowing the exception or wrapping it as the previous exception. The body's first top-level + * Coroutine::causedByCancellation() guard narrows them to the statements before it, the branch it takes + * for a cancellation, and the statements after it unless that branch ends by throwing. + * + * @param DependencyTracker&Scope $scope + */ + private function propagates(Catch_ $catch, Scope $scope): bool + { + if ($catch->var === null || ! is_string($catch->var->name)) { + return false; + } + + $variable = $catch->var->name; + + if ($this->rethrows($catch->stmts, $variable, $scope)) { + return true; + } + + foreach ($catch->stmts as $index => $statement) { + $polarity = $statement instanceof If_ ? $this->coroutineRuleHelper->guardPolarity($statement->cond, $variable) : null; + + if ($polarity === null) { + continue; + } + + // A negated guard's elseif conditions run for a cancellation too, so its path is unknown. + if (! $polarity && $statement->elseifs !== []) { + return false; + } + + // The statements before the guard run for a cancellation too, so they must neither leave the catch + // nor discard the caught exception. + $branch = $polarity ? $statement->stmts : ($statement->else->stmts ?? []); + $last = end($branch); + $after = $last instanceof Expression && $last->expr instanceof Throw_ ? [] : array_slice($catch->stmts, $index + 1); + + return $this->rethrows([...array_slice($catch->stmts, 0, $index), ...$branch, ...$after], $variable, $scope); + } + + return false; + } + + /** + * Determine whether the statements end by throwing the exception or wrapping it, with nothing leaving them first. + * + * @param array $statements + * @param DependencyTracker&Scope $scope + */ + private function rethrows(array $statements, string $variable, Scope $scope): bool + { + $last = end($statements); + + if (! $last instanceof Expression || ! $last->expr instanceof Throw_ || $this->leaves($statements, 0, $variable, $scope)) { + return false; + } + + // The caught exception must reach the throw unchanged, or replaced only by top-level wrappers that keep + // it as their previous exception. A nested catch binding the same name replaces it too. + $writes = $this->coroutineRuleHelper->find( + $statements, + fn (Node $node): bool => $this->coroutineRuleHelper->assigns($node, $variable), + ); + + foreach ($writes as $write) { + if (! $write instanceof Assign + || ! $write->var instanceof Variable + || ! array_any($statements, static fn (Stmt $statement): bool => $statement instanceof Expression && $statement->expr === $write) + || ! $this->wraps($write->expr, $variable, $scope) + ) { + return false; + } + } + + $thrown = $last->expr->expr; + + return ($thrown instanceof Variable && $thrown->name === $variable) || $this->wraps($thrown, $variable, $scope); + } + + /** + * Determine whether the expression creates an exception that keeps the variable as its previous exception. + * + * @param DependencyTracker&Scope $scope + */ + private function wraps(Expr $expr, string $variable, Scope $scope): bool + { + if (! $expr instanceof New_ || ! $expr->class instanceof Name || ! $this->reflectionProvider->hasClass($scope->resolveName($expr->class))) { + return false; + } + + $exception = $this->reflectionProvider->getClass($scope->resolveName($expr->class)); + + // Exception constructor bodies decide which argument becomes the previous exception. + foreach (SourceFiles::forClass($exception, null) as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + + // Coroutine::causedByCancellation() stops at a foreign cancellation, so wrapping in one hides it. + return ! $exception->is(ForeignCancellationException::class) + && $this->forwardsAsPrevious($exception, $expr->getArgs(), [$variable]); + } + + /** + * Determine whether the nodes can leave the catch without the caught exception: a return, a break or + * continue leaving more loops and switches than the given depth plus those the nodes open, or a throw + * of another exception that no try within the nodes catches. Nested functions and classes don't run here. + * + * @param array $nodes + * @param DependencyTracker&Scope $scope + * @param list $caught the types caught by the tries within the nodes that enclose them + */ + private function leaves(array $nodes, int $depth, string $variable, Scope $scope, array $caught = []): bool + { + foreach ($nodes as $node) { + if ($node instanceof Return_) { + return true; + } + + if ($node instanceof Break_ || $node instanceof Continue_) { + if (($node->num instanceof Int_ ? $node->num->value : 1) > $depth) { + return true; + } + + continue; + } + + if ($node instanceof FunctionLike || $node instanceof ClassLike) { + continue; + } + + if ($node instanceof Throw_ + && ! ($node->expr instanceof Variable && $node->expr->name === $variable) + && ! $this->wraps($node->expr, $variable, $scope) + && ! $this->isCaught($node->expr, $caught, $scope)) { + return true; + } + + if ($node instanceof TryCatch) { + $types = []; + + foreach ($node->catches as $catch) { + $types = [...$types, ...$catch->types]; + } + + if ($this->leaves($node->stmts, $depth, $variable, $scope, [...$caught, ...$types]) + || $this->leaves([...$node->catches, ...($node->finally === null ? [] : [$node->finally])], $depth, $variable, $scope, $caught)) { + return true; + } + + continue; + } + + $innerDepth = $node instanceof For_ || $node instanceof Foreach_ || $node instanceof While_ || $node instanceof Do_ || $node instanceof Switch_ + ? $depth + 1 + : $depth; + + foreach ($node->getSubNodeNames() as $subNodeName) { + $children = $node->{$subNodeName}; + + if ($this->leaves(array_filter(is_array($children) ? $children : [$children], static fn (mixed $child): bool => $child instanceof Node), $innerDepth, $variable, $scope, $caught)) { + return true; + } + } + } + + return false; + } + + /** + * Determine whether one of the caught types takes the thrown exception. Only `new` names its class; + * any other thrown value is caught only by Throwable. + * + * @param list $caught + */ + private function isCaught(Expr $thrown, array $caught, Scope $scope): bool + { + $class = $thrown instanceof New_ && $thrown->class instanceof Name ? $scope->resolveName($thrown->class) : Throwable::class; + + if (! $this->reflectionProvider->hasClass($class)) { + return false; + } + + $reflection = $this->reflectionProvider->getClass($class); + + return array_any($caught, static fn (Name $type): bool => $reflection->is($scope->resolveName($type))); + } + + /** + * Determine whether the constructor the arguments are passed to makes one of the variables the previous + * exception: one binds to a native constructor's $previous, or an overriding constructor passes a + * parameter one binds to on to parent::__construct() as the previous exception. + * + * @param array $args + * @param list $variables + */ + private function forwardsAsPrevious(ClassReflection $exception, array $args, array $variables): bool + { + if (! $exception->hasConstructor()) { + return false; + } + + $constructor = $exception->getConstructor(); + $parameters = $constructor->getOnlyVariant()->getParameters(); + $bound = []; + + foreach ($args as $position => $argument) { + // An unpacked argument makes the bindings after it unknown. + if ($argument->unpack) { + break; + } + + if ($argument->value instanceof Variable && in_array($argument->value->name, $variables, true)) { + $parameter = $argument->name?->toString() ?? ($parameters[$position] ?? null)?->getName(); + + if ($parameter !== null) { + $bound[] = $parameter; + } + } + } + + if ($bound === []) { + return false; + } + + if ($constructor->getDeclaringClass()->isBuiltin()) { + return in_array('previous', $bound, true); + } + + [$constructorNode, $declaringClass] = $this->constructorParser->findConstructor($exception) ?? [null, null]; + $parent = $declaringClass?->getParentClass(); + + if ($constructorNode === null || $parent === null) { + return false; + } + + $statements = $constructorNode->stmts ?? []; + + // Only a top-level parent::__construct() runs on every construction, and a parameter must reach it + // unchanged, with no return before it. + foreach ($statements as $statement) { + $call = $statement instanceof Expression ? $statement->expr : null; + + if (! $call instanceof StaticCall + || ! $call->class instanceof Name + || $call->class->toLowerString() !== 'parent' + || ! $call->name instanceof Identifier + || $call->name->toLowerString() !== '__construct' + ) { + continue; + } + + $unchanged = array_filter( + $bound, + fn (string $parameter): bool => $this->coroutineRuleHelper->find( + $statements, + fn (Node $node): bool => $node->getStartFilePos() < $call->getStartFilePos() + && ($node instanceof Return_ || $this->coroutineRuleHelper->assigns($node, $parameter)), + ) === [], + ); + + return $unchanged !== [] && $this->forwardsAsPrevious($parent, $call->getArgs(), array_values($unchanged)); + } + + return false; + } + + /** + * Find the first operation in the block that can deliver cancellation, a call or a discarded sleep, outside nested functions. + * + * @param array $statements + * @param DependencyTracker&Scope $scope + */ + private function findOperation(array $statements, Scope $scope): ?string + { + // A nested try whose receiving catch swallows the cancellation keeps its block's operations from this + // catch, and is reported itself. Its catch and finally bodies still run in this block. + $swallowed = []; + + foreach ($this->coroutineRuleHelper->find($statements, static fn (Node $node): bool => $node instanceof TryCatch) as $try) { + $catch = $try instanceof TryCatch && $try->stmts !== [] ? $this->findCancellationCatch($try, $scope) : null; + + if ($catch !== null && ! $this->propagates($catch, $scope)) { + $swallowed[] = [$try->stmts[0]->getStartFilePos(), $try->stmts[count($try->stmts) - 1]->getEndFilePos()]; + } + } + + // A lazy query runs its queries while it's iterated, so one the block iterates directly runs them there. The + // builders' lazy queries return a LazyCollection, whose each() iterates at once; a connection's cursor is a + // generator, which only foreach consumes. + $consumed = []; + + foreach ($this->coroutineRuleHelper->find($statements, static fn (Node $node): bool => $node instanceof Foreach_ || $node instanceof MethodCall || $node instanceof NullsafeMethodCall) as $consumer) { + if ($consumer instanceof Foreach_) { + $consumed[spl_object_id($consumer->expr)] = true; + } elseif (($consumer instanceof MethodCall || $consumer instanceof NullsafeMethodCall) + && ! $consumer->isFirstClassCallable() + && $consumer->name instanceof Identifier + && $consumer->name->toLowerString() === 'each') { + $consumed[spl_object_id($consumer->var)] = true; + } + } + + $nodes = $this->coroutineRuleHelper->find( + $statements, + static fn (Node $node): bool => ($node instanceof Expression || ($node instanceof CallLike && ! $node->isFirstClassCallable())) + && ! array_any($swallowed, static fn (array $range): bool => $range[0] <= $node->getStartFilePos() && $node->getStartFilePos() <= $range[1]), + ); + + foreach ($nodes as $node) { + $operation = match (true) { + $node instanceof Expression => $this->sleepsWhenDiscarded($node->expr, $scope) ? self::WAIT : null, + $node instanceof CallLike => $this->getOperation($node, $statements, $scope, isset($consumed[spl_object_id($node)])), + default => null, + }; + + if ($operation !== null) { + return $operation; + } + } + + return null; + } + + /** + * Get the operation the call runs on every class it may be made on, matched by the framework's own declarations. + * + * @param array $statements + * @param bool $consumed whether the block iterates the call's result directly + */ + private function getOperation(CallLike $call, array $statements, Scope $scope, bool $consumed): ?string + { + if ($call instanceof FuncCall) { + return $call->name instanceof Name + && isset(self::FUNCTIONS[strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? '')]) + ? self::WAIT + : null; + } + + if (! ($call instanceof MethodCall || $call instanceof NullsafeMethodCall || $call instanceof StaticCall) || ! $call->name instanceof Identifier) { + return null; + } + + $name = $call->name->toString(); + + // The scope is the try's, so a receiver built in the block is typed from what the block assigns. The classes + // keep their type arguments, since a builder's macros depend on its model. + $classes = match (true) { + ! $call instanceof StaticCall => $this->coroutineRuleHelper->getReceiverType($call->var, $statements, $scope)?->getObjectClassReflections() ?? [], + $call->class instanceof Name => $scope->resolveTypeByName($call->class)->getObjectClassReflections(), + default => $this->coroutineRuleHelper->getReceiverType($call->class, $statements, $scope)?->getObjectTypeOrClassStringObjectType()->getObjectClassReflections() ?? [], + }; + + $operation = null; + + foreach ($classes as $class) { + if (! $class->hasMethod($name)) { + return null; + } + + $method = $class->getMethod($name, $scope); + + // A model or Eloquent builder forwards some methods to the builder or query builder that runs them. + // PHPStan wraps the reflection an extension gives, which keeps the forwarded one as its prototype. + while (($prototype = $method->getPrototype()) instanceof ForwardedMethodReflection) { + $method = $prototype->getForwardedMethod(); + } + + $declaringClass = $method->getDeclaringClass()->getName(); + + // The class using a trait declares its methods, so an application model's SoftDeletes methods are matched by their trait. + // The Redis classes' own trait methods, such as RedisProxy's pipeline(), are matched by the class. + if (! isset(self::OPERATIONS[$declaringClass]) + && ! in_array($declaringClass, self::REDIS_CLASSES, true) + // @phpstan-ignore phpstanApi.instanceofAssumption (Only PhpMethodReflection exposes a native method's declaring trait. The SoftDeletes test fails if that changes.) + && $method instanceof PhpMethodReflection + && $method->getDeclaringTrait() !== null) { + $declaringClass = $method->getDeclaringTrait()->getName(); + } + + [$classOperation, $methods] = self::OPERATIONS[$declaringClass] ?? [null, []]; + $lowercase = strtolower($method->getName()); + + if (in_array($declaringClass, self::REDIS_CLASSES, true)) { + $classOperation = $this->runsRedisCommand($method->getName(), $call) ? self::REDIS : null; + } elseif ($consumed && isset(self::LAZY_QUERIES[$declaringClass][$lowercase])) { + $classOperation = self::DATABASE; + } elseif (! isset($methods[$lowercase]) + // Sleep::then() sleeps through goodnight(), which a subclass may override, or its constructor may turn off. + || ($declaringClass === Sleep::class && ! $this->runsFrameworkSleep($class, ['__construct', 'goodnight'])) + ) { + $classOperation = null; + } + + if ($classOperation === null) { + return null; + } + + $operation = $classOperation; + } + + return $operation; + } + + /** + * Determine whether the expression creates a framework sleep and discards it, so it sleeps as it's + * destroyed at the end of the statement: new or a factory method, followed by fluent methods. A sleep + * held in a variable sleeps when that variable releases it. + */ + private function sleepsWhenDiscarded(Expr $expr, Scope $scope): bool + { + // The creating method, the constructor, the fluent methods, the destructor and the sleep it runs must all + // be the framework's. The factories build with new static, so a subclass's constructor runs there too. + $methods = ['__construct', '__destruct', 'goodnight']; + $root = $expr; + + while (($root instanceof MethodCall || $root instanceof NullsafeMethodCall) && $root->name instanceof Identifier) { + $methods[] = $root->name->toString(); + $root = $root->var; + } + + if ($root instanceof StaticCall && $root->name instanceof Identifier && isset(self::SLEEP_FACTORIES[$root->name->toLowerString()])) { + $methods[] = $root->name->toString(); + } elseif (! $root instanceof New_) { + return false; + } + + if (! $root->class instanceof Name || ! $this->reflectionProvider->hasClass($scope->resolveName($root->class))) { + return false; + } + + $sleep = $this->reflectionProvider->getClass($scope->resolveName($root->class)); + + // The statement's value must still be the sleep, not a result such as then()'s. + return $sleep->is(Sleep::class) + && $this->runsFrameworkSleep($sleep, $methods) + && (new ObjectType(Sleep::class))->isSuperTypeOf($scope->getType($expr))->yes(); + } + + /** + * Determine whether the sleep class runs the framework's own declarations of the methods. + * + * @param list $methods + */ + private function runsFrameworkSleep(ClassReflection $sleep, array $methods): bool + { + return array_all( + $methods, + static fn (string $method): bool => $sleep->hasNativeMethod($method) && $sleep->getNativeMethod($method)->getDeclaringClass()->getName() === Sleep::class, + ); + } + + /** + * Determine whether the Redis method runs a command while it's called. A pipeline or transaction + * without a callback returns a client that queues commands for later. + */ + private function runsRedisCommand(string $method, MethodCall|NullsafeMethodCall|StaticCall $call): bool + { + $lowercase = strtolower($method); + + if ($lowercase === 'pipeline' || $lowercase === 'transaction') { + $callback = $call->getArg('callback', 0)?->value; + + return $callback !== null && ! ($callback instanceof ConstFetch && $callback->name->toLowerString() === 'null'); + } + + $proxy = $this->reflectionProvider->getClass(RedisProxy::class); + + // Commands reach the connection through the proxy's __call(), so the proxy doesn't declare them natively. + return isset(self::REDIS_METHODS[$lowercase]) || (! $proxy->hasNativeMethod($method) && $proxy->hasMethod($method)); + } +} diff --git a/src/phpstan/src/Collectors/WorkerStateCallCollector.php b/src/phpstan/src/Collectors/WorkerStateCallCollector.php index 1451cf4c7..9456349f6 100644 --- a/src/phpstan/src/Collectors/WorkerStateCallCollector.php +++ b/src/phpstan/src/Collectors/WorkerStateCallCollector.php @@ -9,13 +9,11 @@ use Hypervel\PHPStan\Rules\Coroutine\WorkerStateHelper; use Hypervel\Routing\Router; use Hypervel\Routing\RouteRegistrar; -use Hypervel\Support\Facades\Config; use Hypervel\Support\Facades\Route; use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\CallLike; use PhpParser\Node\Expr\Closure; -use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; @@ -31,7 +29,6 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; -use PHPStan\Type\ObjectType; use function array_any; use function array_map; @@ -52,11 +49,6 @@ #[AutowiredService] class WorkerStateCallCollector implements Collector { - /** - * The repository methods that change configuration. - */ - private const array CONFIG_WRITES = ['set', 'push', 'prepend', 'offsetset', 'offsetunset']; - /** * The route registration methods whose action parameter takes a route closure. */ @@ -88,7 +80,7 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): ?array { - if (! $this->helper->isEnabled() || $node->isFirstClassCallable()) { + if (! $this->helper->isEnabled()) { return null; } @@ -132,14 +124,14 @@ public function processNode(Node $node, Scope $scope): ?array : ['type' => 'call', 'class' => $function['class'], 'method' => $function['method'], 'callee' => $node->name->name]; } - if (! $this->isConfigWrite($node, $scope)) { + if (! $this->helper->noRuntimeConfigMutation || ! $this->helper->isConfigWrite($node, $scope)) { return null; } - // A repository in a local or a property may be another one. + // A repository in a local, or in a property the constructor did not get injected, may be another one. $receiver = $node instanceof MethodCall ? $this->helper->getConfigReceiver($node->var, $scope) - : ['abstracts' => null, 'parameter' => null]; + : ['abstracts' => null, 'parameter' => null, 'property' => null]; $function = $this->helper->getFunctionFacts($scope, $node); if ($receiver === null || $function === null) { @@ -226,30 +218,4 @@ private function hasContextualAttribute(Param $parameter): bool return false; } - - /** - * Determine whether the call changes configuration: config() with an array, a Config facade write, - * or a repository write method. - */ - private function isConfigWrite(CallLike $call, Scope $scope): bool - { - if ($call instanceof FuncCall) { - $key = $call->getArg('key', 0); - - return $this->helper->isConfigFunction($call, $scope) - && $key !== null - && $scope->getType($key->value)->isArray()->yes(); - } - - if (! $call instanceof MethodCall && ! $call instanceof StaticCall - || ! $call->name instanceof Identifier - || ! in_array($call->name->toLowerString(), self::CONFIG_WRITES, true)) { - return false; - } - - // A method call writes configuration when its receiver is the repository, which the caller checks. - return ! $call instanceof StaticCall - || ($call->class instanceof Name - && (new ObjectType(Config::class))->isSuperTypeOf(new ObjectType($scope->resolveName($call->class)))->yes()); - } } diff --git a/src/phpstan/src/Collectors/WorkerStateClassCollector.php b/src/phpstan/src/Collectors/WorkerStateClassCollector.php index 53ccf0042..b15b01f16 100644 --- a/src/phpstan/src/Collectors/WorkerStateClassCollector.php +++ b/src/phpstan/src/Collectors/WorkerStateClassCollector.php @@ -6,30 +6,63 @@ use Hypervel\Container\Attributes\Singleton; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Database\Eloquent\Attributes\ObservedBy; +use Hypervel\Database\Eloquent\Model; +use Hypervel\PHPStan\Reflection\SourceFiles; use Hypervel\PHPStan\Rules\Coroutine\WorkerStateHelper; +use Hypervel\PHPStan\Support\RuntimeInputs; use PhpParser\Node; use PhpParser\Node\Expr\Variable; +use PHPStan\Analyser\DependencyTracker; use PHPStan\Analyser\Scope; use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\InClassNode; - +use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ReflectionProvider; + +use function array_all; +use function array_is_list; +use function array_keys; +use function array_walk_recursive; +use function is_array; use function is_string; /** - * Collects the classes that are worker-lifetime roots by declaration, and the request or operation - * state their promoted constructor parameters keep. + * Collects the classes that are worker-lifetime roots by declaration, the request or operation + * state their promoted constructor parameters keep, and the observers models register when they boot. + * + * @phpstan-type ClassFacts array{class: string, singleton: bool, queued: bool, captures: list, observers: list, events: list} * - * @implements Collector}> + * @implements Collector */ #[AutowiredService] class WorkerStateClassCollector implements Collector { + /** + * The events every model makes observable, as HasEvents::getObservableEvents() lists them. + */ + private const array MODEL_EVENTS = [ + 'retrieved', 'creating', 'created', 'updating', 'updated', + 'saving', 'saved', 'restoring', 'restored', 'replicating', + 'trashed', 'deleting', 'deleted', 'forceDeleting', 'forceDeleted', + ]; + + /** + * The model methods that decide which #[ObservedBy] observers handle which events. + */ + private const array OBSERVER_REGISTRATION = [ + 'bootHasEvents', 'observe', 'resolveObserveAttributes', 'registerObserver', 'getObservableEvents', + ]; + /** * Create a new collector instance. */ - public function __construct(private WorkerStateHelper $helper) - { + public function __construct( + private ReflectionProvider $reflectionProvider, + private WorkerStateHelper $helper, + private RuntimeInputs $runtimeInputs, + ) { } /** @@ -41,11 +74,12 @@ public function getNodeType(): string } /** - * Collect the class's root declarations and promoted state captures. + * Collect the class's root declarations, promoted state captures and model observers. * * @param InClassNode $node + * @param DependencyTracker&Scope $scope * - * @return null|array{class: string, singleton: bool, queued: bool, captures: list} + * @return null|ClassFacts */ public function processNode(Node $node, Scope $scope): ?array { @@ -63,7 +97,7 @@ public function processNode(Node $node, Scope $scope): ?array $constructor = $node->getOriginalNode()->getMethod('__construct'); // A constructor marked as boot-time or test-only keeps its parameters by design. - $parameters = $constructor === null || $this->helper->hasLifetimeAttribute($class->getNativeMethod('__construct')) + $parameters = $constructor === null || $this->helper->getLifetimeAttribute($class->getNativeMethod('__construct')) !== null ? [] : $constructor->params; @@ -79,7 +113,9 @@ public function processNode(Node $node, Scope $scope): ?array } } - if (! $singleton && ! $queued && $captures === []) { + [$observers, $events] = $concrete && $class->is(Model::class) ? $this->getObservers($class, $scope) : [[], []]; + + if (! $singleton && ! $queued && $captures === [] && $observers === []) { return null; } @@ -88,6 +124,63 @@ public function processNode(Node $node, Scope $scope): ?array 'singleton' => $singleton, 'queued' => $queued, 'captures' => $captures, + 'observers' => $observers, + 'events' => $events, ]; } + + /** + * Get the observers #[ObservedBy] registers when the model boots, with the events they can handle. + * + * HasEvents registers them lazily at the model's first use, so the booted dispatcher lacks them. + * A model that customizes the registration, or whose observable events are not constant, gets none. + * + * @param DependencyTracker&Scope $scope + * + * @return array{list, list} + */ + private function getObservers(ClassReflection $model, Scope $scope): array + { + $observers = []; + + // resolveObserveAttributes() merges each ancestor's own attributes below Model. + foreach ([$model, ...$model->getParents()] as $ancestor) { + if ($ancestor->getName() === Model::class) { + break; + } + + foreach ($ancestor->getNativeReflection()->getAttributes(ObservedBy::class) as $attribute) { + $arguments = $attribute->getArguments(); + + array_walk_recursive($arguments, static function (mixed $observer) use (&$observers): void { + if (is_string($observer)) { + $observers[$observer] = true; + } + }); + } + + // Exported nodes keep the argument expressions, not the values of constants they read. + foreach (SourceFiles::forAttributeArguments($this->reflectionProvider, $ancestor, ObservedBy::class) as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + } + + if ($observers === [] + || ! array_all(self::OBSERVER_REGISTRATION, static fn (string $method): bool => $model->getNativeMethod($method)->getDeclaringClass()->getName() === Model::class)) { + return [[], []]; + } + + // Property defaults are not declarations, so their files are tracked. + $this->runtimeInputs->trackSources($scope, $model->getName(), Model::class); + + $observables = $model->getNativeReflection()->getProperty('observables')->getDefaultValue(); + + if (! is_array($observables) || ! array_is_list($observables) || ! array_all($observables, static fn (mixed $event): bool => is_string($event))) { + return [[], []]; + } + + return [array_keys($observers), [...self::MODEL_EVENTS, ...$observables]]; + } } diff --git a/src/phpstan/src/Collectors/WorkerStateConfigOffsetCollector.php b/src/phpstan/src/Collectors/WorkerStateConfigOffsetCollector.php index b660da2de..5c68f59ff 100644 --- a/src/phpstan/src/Collectors/WorkerStateConfigOffsetCollector.php +++ b/src/phpstan/src/Collectors/WorkerStateConfigOffsetCollector.php @@ -9,20 +9,19 @@ use PhpParser\Node\Expr\ArrayDimFetch; use PhpParser\Node\Expr\Assign; use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Stmt; -use PhpParser\Node\Stmt\Expression; use PhpParser\Node\Stmt\Unset_; use PHPStan\Analyser\Scope; use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\AutowiredService; /** - * Collects the statements that set or unset an offset of the global configuration repository, such as - * config()['app.locale'] = 'fr', which change it like Repository::set(). + * Collects the assignments and unset() calls that set or unset an offset of the global configuration + * repository, such as config()['app.locale'] = 'fr', which change it like Repository::set(). Each is + * read with the scope at the write, wherever it sits, such as inside a return or an arrow function. * * @phpstan-import-type ConfigWrite from WorkerStateHelper * - * @implements Collector + * @implements Collector */ #[AutowiredService] class WorkerStateConfigOffsetCollector implements Collector @@ -39,29 +38,23 @@ public function __construct(private WorkerStateHelper $helper) */ public function getNodeType(): string { - return Stmt::class; + // Assignments are expressions and unset() is a statement. + return Node::class; } /** - * Collect the statement's config offset write. - * - * @param Stmt $node + * Collect the node's config offset write. * * @return null|ConfigWrite */ public function processNode(Node $node, Scope $scope): ?array { - if (! $this->helper->noRuntimeConfigMutation) { + if ((! $node instanceof Assign && ! $node instanceof AssignOp && ! $node instanceof Unset_) + || ! $this->helper->noRuntimeConfigMutation) { return null; } - $targets = match (true) { - $node instanceof Expression && ($node->expr instanceof Assign || $node->expr instanceof AssignOp) => [$node->expr->var], - $node instanceof Unset_ => $node->vars, - default => [], - }; - - foreach ($targets as $target) { + foreach ($node instanceof Unset_ ? $node->vars : [$node->var] as $target) { if (! $target instanceof ArrayDimFetch) { continue; } @@ -76,7 +69,7 @@ public function processNode(Node $node, Scope $scope): ?array return $function === null ? null - : ['type' => 'config', 'line' => $node->getStartLine(), ...$receiver, 'function' => $function]; + : ['type' => 'config', 'line' => $target->getStartLine(), ...$receiver, 'function' => $function]; } return null; diff --git a/src/phpstan/src/Collectors/WorkerStateGlobalWriteCollector.php b/src/phpstan/src/Collectors/WorkerStateGlobalWriteCollector.php new file mode 100644 index 000000000..550b5c62f --- /dev/null +++ b/src/phpstan/src/Collectors/WorkerStateGlobalWriteCollector.php @@ -0,0 +1,362 @@ +> + */ +#[AutowiredService] +class WorkerStateGlobalWriteCollector implements Collector +{ + /** + * Create a new collector instance. + */ + public function __construct(private WorkerStateHelper $helper) + { + } + + /** + * Get the node type the collector inspects. + */ + public function getNodeType(): string + { + // Writes are expressions, unset() is a statement, and bindings are read from each function's body. + return Node::class; + } + + /** + * Collect the node's global writes and the request state written through a variable. + * + * @return null|list + */ + public function processNode(Node $node, Scope $scope): ?array + { + if (! $this->helper->checkStaticStateMutation) { + return null; + } + + $writes = match (true) { + // @phpstan-ignore phpstanApi.instanceofAssumption (PHPStan's function nodes carry the scope inside the function, and a collector takes one node type.), phpstanApi.instanceofAssumption (PHPStan's function nodes carry the scope inside the function, and a collector takes one node type.), phpstanApi.instanceofAssumption (PHPStan's function nodes carry the scope inside the function, and a collector takes one node type.) + $node instanceof InClassMethodNode, $node instanceof InFunctionNode, $node instanceof InClosureNode => $this->findBoundWrites($node->getOriginalNode()->stmts ?? [], $scope), + // @phpstan-ignore phpstanApi.class (PHPStan's normalized write, which also covers by-reference arguments; no API node covers them.), phpstanApi.method (PHPStan's normalized write, which also covers by-reference arguments; no API node covers them.), phpstanApi.method (PHPStan's normalized write, which also covers by-reference arguments; no API node covers them.) + $node instanceof VariableAssignNode => $this->getArgumentWrites($node->getVariable(), $node->getAssignedExpr(), $scope), + // @phpstan-ignore phpstanApi.class (PHPStan's normalized write, which also covers by-reference arguments; no API node covers them.), phpstanApi.method (PHPStan's normalized write, which also covers by-reference arguments; no API node covers them.), phpstanApi.method (PHPStan's normalized write, which also covers by-reference arguments; no API node covers them.) + $node instanceof PropertyAssignNode => $this->getArgumentWrites($node->getPropertyFetch(), $node->getAssignedExpr(), $scope), + default => $this->getWrites($node, $scope), + }; + + return $writes === [] ? null : $writes; + } + + /** + * Get the evidence that PHPStan's normalized write of a target may be a by-reference argument's: it writes a + * by-reference parameter's type, or writes `$GLOBALS`, whose offset arguments PHPStan reports like any offset + * write. PHPStan writes such types elsewhere too, such as foreach values, so the rule keeps an argument only + * where such a write is at the argument's own position. + * + * @return list + */ + private function getArgumentWrites(Expr $target, Expr $value, Scope $scope): array + { + // @phpstan-ignore phpstanApi.instanceofAssumption (PHPStan's call handling writes a by-reference argument with this expression.) + return $value instanceof NativeTypeExpr || ($target instanceof Variable && $target->name === 'GLOBALS') + ? [['type' => 'argument', 'target' => $this->getTargetKey($target, $scope)]] + : []; + } + + /** + * Get the `$GLOBALS` writes the node makes, the `$GLOBALS` storage it passes to a call, and the request + * state it writes through a variable. + * + * @return list + */ + private function getWrites(Node $node, Scope $scope): array + { + $writes = []; + + // PHPStan also passes a nullsafe call as a method call. + $arguments = $node instanceof NullsafeMethodCall ? [] : $this->getArgumentTargets($node); + + foreach ($arguments['GLOBALS'] ?? [] as $target) { + $function = $this->helper->getFunctionFacts($scope, $target); + + if ($function !== null) { + $writes[] = ['type' => 'global', 'line' => $target->getStartLine(), 'name' => $this->getGlobalsName($target), 'target' => $this->getTargetKey($target, $scope), 'state' => null, 'argument' => true, 'function' => $function]; + } + } + + foreach ($this->getTargets($node) as $root => $targets) { + // A function can't bind $this to a global variable, so its writes never join a bound one. + if ($root === 'this') { + continue; + } + + foreach ($targets as $target) { + // Only a whole value says what the target holds; a destructured leaf gets part of it. + $state = match (true) { + ($node instanceof Assign || $node instanceof AssignRef || $node instanceof Coalesce) && $node->var === $target => $this->helper->getState($scope->getType($node->expr)), + $node instanceof Foreach_ && $node->valueVar === $target => $this->helper->getState($scope->getType($node->expr)->getIterableValueType()), + $node instanceof Foreach_ && $node->keyVar === $target => $this->helper->getState($scope->getType($node->expr)->getIterableKeyType()), + default => null, + }; + + if ($root === 'GLOBALS') { + $function = $this->helper->getFunctionFacts($scope, $target); + + if ($function !== null) { + $writes[] = ['type' => 'global', 'line' => $target->getStartLine(), 'name' => $this->getGlobalsName($target), 'target' => $this->getTargetKey($target, $scope), 'state' => $state, 'argument' => false, 'function' => $function]; + } + } elseif ($state !== null) { + $writes[] = ['type' => 'value', 'target' => $this->getTargetKey($target, $scope), 'state' => $state]; + } + } + } + + return $writes; + } + + /** + * Find the writes through names the statements bind to global storage, in order: with a top-level `global` + * statement, or a top-level reference assignment between a plain variable and global storage. One under a + * condition may not run, so it binds nothing. Nested functions have their own bindings. + * + * @param array $statements + * + * @return list + */ + private function findBoundWrites(array $statements, Scope $scope): array + { + $bound = []; + $writes = []; + + foreach ($statements as $statement) { + if ($statement instanceof Global_) { + foreach ($statement->vars as $variable) { + if ($variable instanceof Variable && is_string($variable->name)) { + $bound[$variable->name] = true; + } + } + + continue; + } + + // Read before the statement detaches the alias from what it was bound to. + $alias = $statement instanceof Expression && $statement->expr instanceof AssignRef + ? CoroutineRuleHelper::getStorageAlias($statement->expr, $bound + ['GLOBALS' => true]) + : null; + + foreach ($bound === [] ? [] : $this->getOwnNodes($statement) as $node) { + foreach (CoroutineRuleHelper::getDetachedNames($node) as $name) { + unset($bound[$name]); + } + + foreach ([[false, $this->getTargets($node)], [true, $this->getArgumentTargets($node)]] as [$argument, $named]) { + foreach (array_intersect_key($named, $bound) as $name => $targets) { + foreach ($targets as $target) { + $function = $this->helper->getFunctionFacts($scope, $target); + + if ($function !== null) { + $writes[] = ['type' => 'bound', 'line' => $target->getStartLine(), 'name' => '$' . $name, 'target' => $this->getTargetKey($target, $scope), 'state' => null, 'argument' => $argument, 'function' => $function]; + } + } + } + } + } + + if ($alias !== null) { + $bound[$alias[0]] = true; + } + } + + return $writes; + } + + /** + * Get the targets the node writes, keyed by the name of the variable they're rooted at: an assignment's, + * a foreach key's or value's and a catch binding's (CoroutineRuleHelper::getWriteTargets()), an + * increment's or decrement's, and each offset or property unset() removes. A variable the node unsets + * or binds by reference points its name elsewhere rather than being written, and a chain rooted at a + * call or a static property isn't a variable's storage. + * + * @return array> + */ + private function getTargets(Node $node): array + { + $targets = match (true) { + $node instanceof PreInc, $node instanceof PreDec, $node instanceof PostInc, $node instanceof PostDec => [$node->var], + $node instanceof Unset_ => $node->vars, + default => CoroutineRuleHelper::getWriteTargets($node), + }; + + $rebound = array_fill_keys(CoroutineRuleHelper::getDetachedNames($node), true); + $named = []; + + foreach ($targets as $target) { + $root = $this->getRootName($target); + + if ($root !== null && ! ($target instanceof Variable && isset($rebound[$root]))) { + $named[$root][] = $target; + } + } + + return $named; + } + + /** + * Get the arguments a call passes that a by-reference parameter could write, keyed by the name of the variable + * they're rooted at. Creating a first-class callable passes nothing. + * + * @return array> + */ + private function getArgumentTargets(Node $node): array + { + if (! ($node instanceof FuncCall || $node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof StaticCall || $node instanceof New_) + || $node->isFirstClassCallable()) { + return []; + } + + $named = []; + + foreach ($node->getArgs() as $argument) { + $root = $this->getRootName($argument->value); + + if (! $argument->unpack && $root !== null) { + $named[$root][] = $argument->value; + } + } + + return $named; + } + + /** + * Get the name of the variable an offset or property chain is rooted at, or null when a call or a static + * property holds it rather than a variable. + */ + private function getRootName(Expr $target): ?string + { + while ($target instanceof ArrayDimFetch || $target instanceof PropertyFetch) { + $target = $target->var; + } + + return $target instanceof Variable && is_string($target->name) ? $target->name : null; + } + + /** + * Get the key the rule joins a bound write and the state written at its target on. Trait code is collected under + * the file using the trait, with positions in the trait's own file, so the key includes the enclosing class and + * function. + */ + private function getTargetKey(Expr $target, Scope $scope): string + { + return sprintf('%s::%s@%d', $scope->getClassReflection()?->getName() ?? '', $scope->getFunction()?->getName() ?? '', $target->getStartFilePos()); + } + + /** + * Get how a diagnostic names the `$GLOBALS` entry the target writes. + */ + private function getGlobalsName(Expr $target): string + { + $key = null; + + while ($target instanceof ArrayDimFetch || $target instanceof PropertyFetch) { + if ($target instanceof ArrayDimFetch && $target->var instanceof Variable) { + $key = $target->dim instanceof String_ ? $target->dim->value : null; + } + + $target = $target->var; + } + + return $key === null ? '$GLOBALS' : sprintf("\$GLOBALS['%s']", $key); + } + + /** + * Get the nodes of a statement, in order, without entering nested functions or classes. + * + * @return list + */ + private function getOwnNodes(Node $statement): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $found = []; + + public function enterNode(Node $node): ?int + { + if ($node instanceof FunctionLike || $node instanceof ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + + $this->found[] = $node; + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse([$statement]); + + return $visitor->found; + } +} diff --git a/src/phpstan/src/Collectors/WorkerStateParameterCollector.php b/src/phpstan/src/Collectors/WorkerStateParameterCollector.php index 808f5306e..4cb98ffbd 100644 --- a/src/phpstan/src/Collectors/WorkerStateParameterCollector.php +++ b/src/phpstan/src/Collectors/WorkerStateParameterCollector.php @@ -20,7 +20,8 @@ /** * Collects the reassigned parameters whose injected values the worker-state checks rely on: a - * constructor's request or operation state, and a configuration repository. + * constructor's request or operation state, a configuration repository, and, for restricted calls, + * any method parameter that may hold an object. * * @phpstan-import-type FunctionFacts from WorkerStateHelper * @@ -85,8 +86,10 @@ public function processNode(Node $node, Scope $scope): ?array $constructor = ! $function['closure'] && $function['method'] === '__construct'; + // A method's object parameter may be the evidence that a restricted call reaches the worker-shared instance. if (! $this->helper->isConfigRepositoryType($parameter->getType()) - && ! ($constructor && $this->helper->getState($parameter->getType()) !== null)) { + && ! ($constructor && $this->helper->getState($parameter->getType()) !== null) + && ! ($this->helper->checkRestrictedCalls && ! $function['closure'] && ! $parameter->getType()->isObject()->no())) { return null; } diff --git a/src/phpstan/src/Collectors/WorkerStateWriteCollector.php b/src/phpstan/src/Collectors/WorkerStateWriteCollector.php index 03dbcee92..db7f6e8f1 100644 --- a/src/phpstan/src/Collectors/WorkerStateWriteCollector.php +++ b/src/phpstan/src/Collectors/WorkerStateWriteCollector.php @@ -33,7 +33,7 @@ * * @phpstan-import-type FunctionFacts from WorkerStateHelper * - * @implements Collector + * @implements Collector */ #[AutowiredService] class WorkerStateWriteCollector implements Collector @@ -59,11 +59,11 @@ public function getNodeType(): string * * @param PropertyAssignNode $node * - * @return null|array{static: bool, class: string, property: string, line: int, function: FunctionFacts, state: null|string, parameter: null|string, cacheKey: bool} + * @return null|array{static: bool, class: string, property: string, line: int, function: FunctionFacts, state: null|string, parameter: null|string, cacheKey: bool, copied: bool} */ public function processNode(Node $node, Scope $scope): ?array { - if (! $this->helper->isEnabled()) { + if (! $this->helper->hasStateChecks()) { return null; } @@ -102,9 +102,23 @@ public function processNode(Node $node, Scope $scope): ?array $cacheKey = false; $storage = $scope->getType($fetch); + // An offset write on a property WorkerStateConfigOffsetCollector recognizes as the global repository + // changes configuration, as its set() does, which noRuntimeConfigMutation owns. Other repository + // properties are ordinary object properties. + // @phpstan-ignore phpstanApi.class (The offset chain PHPStan builds inside a property write.), phpstanApi.class (The offset chain PHPStan builds inside a property write.), phpstanApi.class (The offset chain PHPStan builds inside a property write.) + if (($value instanceof SetOffsetValueTypeExpr || $value instanceof SetExistingOffsetValueTypeExpr || $value instanceof UnsetOffsetExpr) + && $this->helper->getConfigReceiver($fetch, $scope) !== null) { + return null; + } + // Storage the method just reset holds only what the method itself adds. $growingStorage = $storage->isArray()->yes() && ! $storage->isConstantArray()->yes(); + // A write that replaces the property, or changes only values such as arrays and strings (including an + // array it creates from null), changes only a copied instance's own value. One into an object the + // property holds, or may hold, changes the object a clone shares with the container's instance. + $copied = true; + // @phpstan-ignore phpstanApi.class (The offset chain PHPStan builds inside a property write.), phpstanApi.class (The offset chain PHPStan builds inside a property write.) while ($value instanceof SetOffsetValueTypeExpr || $value instanceof SetExistingOffsetValueTypeExpr) { // @phpstan-ignore phpstanApi.method (The offset chain PHPStan builds inside a property write.) @@ -115,10 +129,19 @@ public function processNode(Node $node, Scope $scope): ?array && $this->helper->readsRequestState($dim, $scope) && ! $this->helper->isBoundedKey($scope->getType($dim))); + // @phpstan-ignore phpstanApi.method (The offset chain PHPStan builds inside a property write.) + $copied = $copied && $scope->getType($value->getVar())->isObject()->no(); + // @phpstan-ignore phpstanApi.method (The offset chain PHPStan builds inside a property write.) $value = $value->getValue(); } + // @phpstan-ignore phpstanApi.class (The offset chain PHPStan builds inside a property write.) + if ($value instanceof UnsetOffsetExpr) { + // @phpstan-ignore phpstanApi.method (The offset chain PHPStan builds inside a property write.) + $copied = $copied && $scope->getType($value->getVar())->isObject()->no(); + } + // @phpstan-ignore phpstanApi.class (The offset chain PHPStan builds inside a property write.) $state = $value instanceof UnsetOffsetExpr ? null : $this->helper->getState($scope->getType($value)); @@ -156,6 +179,7 @@ public function processNode(Node $node, Scope $scope): ?array 'state' => $state, 'parameter' => $parameter, 'cacheKey' => $cacheKey, + 'copied' => $copied, ]; } } diff --git a/src/phpstan/src/Concerns/InspectsConnections.php b/src/phpstan/src/Concerns/InspectsConnections.php new file mode 100644 index 000000000..af14a70f7 --- /dev/null +++ b/src/phpstan/src/Concerns/InspectsConnections.php @@ -0,0 +1,141 @@ +name instanceof Identifier || $node->name->toLowerString() !== 'transaction') { + return null; + } + + if ($node instanceof StaticCall) { + if (! $node->class instanceof Name || ! ReflectionHelper::isFacade($node->class, DB::class, 'DB')) { + return null; + } + + $type = new ObjectType($scope->resolveName($node->class)); + } else { + $type = $scope->getType($node->var); + + if (! (new ObjectType(ConnectionInterface::class))->isSuperTypeOf($type)->yes()) { + return null; + } + } + + return $this->runsConnectionMethod($type, 'transaction', $scope) ? $type : null; + } + + /** + * Get the key that identifies a `$this->property` holding a connection, or null for any other expression. + * + * Only an instance property the class declares without a get hook returns the same value on every read. + */ + private function getPropertyKey(Expr $expr, Scope $scope): ?string + { + if (! $expr instanceof PropertyFetch + || ! $expr->var instanceof Variable + || $expr->var->name !== 'this' + || ! $expr->name instanceof Identifier) { + return null; + } + + $class = $scope->getClassReflection(); + $name = $expr->name->toString(); + + if ($class === null || ! $class->hasNativeProperty($name)) { + return null; + } + + $property = $class->getNativeProperty($name); + + return $property->isStatic() || ! $property->isVirtual()->no() || $property->hasHook('get') ? null : $this->getPropertyWriteKey($expr); + } + + /** + * Get the key a write to `$this->property` drops, matching getPropertyKey(), or null for any other target. + */ + private function getPropertyWriteKey(?Expr $target): ?string + { + return $target instanceof PropertyFetch + && $target->var instanceof Variable + && $target->var->name === 'this' + && $target->name instanceof Identifier + ? 'this->' . $target->name->toString() + : null; + } + + /** + * Get the key that identifies a literal `DB::connection('name')` call, or null for any other expression. + */ + private function getConnectionName(Expr $expr, Scope $scope): ?string + { + if (! $this->isFacadeConnectionCall($expr, $scope)) { + return null; + } + + $name = $expr->getArg('name', 0)?->value; + + return $name instanceof String_ ? DB::class . '::connection:' . $name->value : null; + } + + /** + * Determine whether the expression is a `DB::connection()` call running the facade's own connection(). + * + * @phpstan-assert-if-true StaticCall $expr + */ + private function isFacadeConnectionCall(Expr $expr, Scope $scope): bool + { + return $expr instanceof StaticCall + && $expr->class instanceof Name + && $expr->name instanceof Identifier + && $expr->name->toLowerString() === 'connection' + && ReflectionHelper::isFacade($expr->class, DB::class, 'DB') + && $this->runsConnectionMethod(new ObjectType($scope->resolveName($expr->class)), 'connection', $scope); + } + + /** + * Determine whether every class of the type runs the framework's own declaration of the method: + * the connection contract's, Connection's, PdoConnection's, the database manager's or the DB + * facade's. An override's body is unknown. + */ + private function runsConnectionMethod(Type $type, string $method, Scope $scope): bool + { + $classes = $type->getObjectClassReflections(); + + return $classes !== [] && array_all( + $classes, + static fn (ClassReflection $class): bool => $class->hasMethod($method) + && in_array($class->getMethod($method, $scope)->getDeclaringClass()->getName(), [ConnectionInterface::class, Connection::class, PdoConnection::class, DatabaseManager::class, DB::class], true), + ); + } +} diff --git a/src/phpstan/src/Concerns/InspectsQueuedJobs.php b/src/phpstan/src/Concerns/InspectsQueuedJobs.php index e2bb9a696..e00a2f2fe 100644 --- a/src/phpstan/src/Concerns/InspectsQueuedJobs.php +++ b/src/phpstan/src/Concerns/InspectsQueuedJobs.php @@ -4,6 +4,7 @@ namespace Hypervel\PHPStan\Concerns; +use Hypervel\PHPStan\Reflection\ReflectionHelper; use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; @@ -53,23 +54,6 @@ private function isDispatchableClass(ClassReflection $classReflection): bool && ! $classReflection->isAbstract(); } - /** - * Determine whether the static call target names the given facade. - * - * Matches the facade itself, a subclass of it, and the root namespace alias - * Hypervel registers for it (`\Bus`, `\DB`, ...). - */ - private function isFacade(Name $class, string $facade, string $alias): bool - { - $className = $class->toString(); - - if ($className === $facade || $className === $alias) { - return true; - } - - return (new ObjectType($facade))->isSuperTypeOf(new ObjectType($className))->yes(); - } - /** * Get the lowercase name of the method the call makes when it is one of the given * facade or receiver methods, or null for any other call. @@ -87,7 +71,7 @@ private function getQueueMethod(CallLike $node, Scope $scope, array $facadeMetho $method = $node->name->toLowerString(); foreach ($facadeMethods as $alias => [$facade, $methods]) { - if (in_array($method, $methods, true) && $this->isFacade($node->class, $facade, $alias)) { + if (in_array($method, $methods, true) && ReflectionHelper::isFacade($node->class, $facade, $alias)) { return $method; } } diff --git a/src/phpstan/src/Internal/ConfigHelper.php b/src/phpstan/src/Internal/ConfigHelper.php index 1e8981775..d8fc3e5a1 100644 --- a/src/phpstan/src/Internal/ConfigHelper.php +++ b/src/phpstan/src/Internal/ConfigHelper.php @@ -6,13 +6,18 @@ use Closure; use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Container\Container; use Hypervel\Contracts\Config\Repository; +use Hypervel\PHPStan\Rules\Coroutine\WorkerStateHelper; use Hypervel\PHPStan\Support\ConfigParser; +use Hypervel\PHPStan\Support\RuntimeInputs; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Config; use InvalidArgumentException; use PhpParser\Node\Arg; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\CallLike; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; @@ -38,6 +43,7 @@ use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; +use function array_all; use function array_any; use function in_array; use function sprintf; @@ -55,8 +61,11 @@ class ConfigHelper /** * Create a new helper instance. */ - public function __construct(private ConfigParser $configParser) - { + public function __construct( + private ConfigParser $configParser, + private RuntimeInputs $runtimeInputs, + private WorkerStateHelper $workerStateHelper, + ) { } /** @@ -163,8 +172,11 @@ public function getMethodReturnType(CallLike $call, Scope $scope): ?Type } /** - * Get the name of the standard repository or Config facade value method the call makes, - * or null when it calls another method or a custom implementation. + * Get the name of the standard repository or Config facade value method the call makes on the + * application's configuration, or null when it calls another method, a custom implementation, + * or a repository that may not be the application's. + * + * @param DependencyTracker&Scope $scope */ public function getMethodName(CallLike $call, Scope $scope): ?string { @@ -179,6 +191,10 @@ public function getMethodName(CallLike $call, Scope $scope): ?string } if ($call instanceof MethodCall) { + if (! $this->isApplicationRepository($call->var, $scope)) { + return null; + } + [$calledOnType, $receiverClass] = [$scope->getType($call->var), Repository::class]; } elseif ($call->class instanceof Name) { [$calledOnType, $receiverClass] = [new ObjectType($scope->resolveName($call->class)), Config::class]; @@ -210,6 +226,37 @@ public function getMethodName(CallLike $call, Scope $scope): ?string return $methodName; } + /** + * Determine whether the expression is the application's repository: config() without arguments, + * or a constant resolution of it from the application container. + * + * The application's values say nothing about another repository, such as one built directly or + * received as a parameter or property, which may hold other values. + * + * @param DependencyTracker&Scope $scope + */ + private function isApplicationRepository(Expr $expr, Scope $scope): bool + { + if ($expr instanceof FuncCall && $this->workerStateHelper->isConfigFunction($expr, $scope)) { + return $expr->getRawArgs() === []; + } + + $abstracts = $expr instanceof CallLike ? $this->workerStateHelper->getApplicationResolvedAbstracts($expr, $scope) : null; + + if ($abstracts === null) { + return false; + } + + // The booted aliases decide which abstracts name the repository. + $this->runtimeInputs->track($scope); + + // The analysis always runs on the framework container: the booted application or a test's own. + /** @var Container $container */ + $container = $this->workerStateHelper->getContainer(); + + return array_all($abstracts, static fn (string $abstract): bool => $container->getAlias($abstract) === 'config'); + } + /** * Determine whether the method is one of the standard repository's, its contract's or the * Config facade's value methods. A custom implementation keeps its declared return type. diff --git a/src/phpstan/src/Reflection/ReflectionHelper.php b/src/phpstan/src/Reflection/ReflectionHelper.php index 9cd0d5467..50cd1e605 100644 --- a/src/phpstan/src/Reflection/ReflectionHelper.php +++ b/src/phpstan/src/Reflection/ReflectionHelper.php @@ -4,10 +4,12 @@ namespace Hypervel\PHPStan\Reflection; +use PhpParser\Node\Name; use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\Mixin\MixinMethodsClassReflectionExtension; use PHPStan\Reflection\Mixin\MixinPropertiesClassReflectionExtension; +use PHPStan\Type\ObjectType; use function array_key_exists; use function array_values; @@ -91,6 +93,23 @@ private static function resolveMethodTag(ClassReflection $classReflection, strin ->hasMethod($classReflection, $methodName); } + /** + * Determine whether the static call target names the given facade. + * + * Matches the facade itself, a subclass of it, and the root namespace alias + * Hypervel registers for it (`\Bus`, `\DB`, ...). + */ + public static function isFacade(Name $class, string $facade, string $alias): bool + { + $className = $class->toString(); + + if ($className === $facade || $className === $alias) { + return true; + } + + return (new ObjectType($facade))->isSuperTypeOf(new ObjectType($className))->yes(); + } + /** * Find a class attribute as the runtime reads it: on each class of the hierarchy, then the * traits it uses directly, then its parent (`ClassMetadataCache::getAttribute()` and diff --git a/src/phpstan/src/Reflection/SourceFiles.php b/src/phpstan/src/Reflection/SourceFiles.php index 40335bf2a..4ed3d636e 100644 --- a/src/phpstan/src/Reflection/SourceFiles.php +++ b/src/phpstan/src/Reflection/SourceFiles.php @@ -110,16 +110,19 @@ public static function forConstant(ReflectionProvider $reflectionProvider, Class } /** - * Get the files of the constants the arguments of a class's or trait's attribute read. + * Get the files of the constants the arguments of a class's or trait's attribute read, or those of + * the attribute on one of its methods. * * @return list */ - public static function forAttributeArguments(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $attributeClass): array + public static function forAttributeArguments(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $attributeClass, ?string $method = null): array { $files = []; $constants = []; + $reflection = $classReflection->getNativeReflection(); + $attributes = $method === null ? $reflection->getAttributes($attributeClass) : $reflection->getMethod($method)->getAttributes($attributeClass); - foreach ($classReflection->getNativeReflection()->getAttributes($attributeClass) as $attribute) { + foreach ($attributes as $attribute) { foreach ($attribute->getArgumentsExpressions() as $expression) { self::addExpressionFiles($reflectionProvider, $classReflection, $expression, $files, $constants); } @@ -128,6 +131,31 @@ public static function forAttributeArguments(ReflectionProvider $reflectionProvi return array_keys($files); } + /** + * Get the files a method parameter's declared default comes from: the one declaring the method and + * those of the constants the default reads. + * + * @return list + */ + public static function forParameterDefault(ReflectionProvider $reflectionProvider, ClassReflection $classReflection, string $method, string $parameter): array + { + $files = []; + $constants = []; + $reflection = $classReflection->getNativeReflection()->getMethod($method); + + if ($reflection->getFileName() !== false) { + $files[$reflection->getFileName()] = true; + } + + foreach ($reflection->getParameters() as $candidate) { + if ($candidate->getName() === $parameter && $candidate->isDefaultValueAvailable()) { + self::addExpressionFiles($reflectionProvider, $classReflection, $candidate->getDefaultValueExpression(), $files, $constants); + } + } + + return array_keys($files); + } + /** * Add the files a constant read through a class takes its value from. * diff --git a/src/phpstan/src/Rules/BindingCycleRule.php b/src/phpstan/src/Rules/BindingCycleRule.php new file mode 100644 index 000000000..7a6171a34 --- /dev/null +++ b/src/phpstan/src/Rules/BindingCycleRule.php @@ -0,0 +1,143 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkBindingCycles%')] +class BindingCycleRule implements Rule +{ + use HasContainer; + + /** + * The lowercase names of the binding methods checked. The *If() variants are skipped: the booted + * state can't show whether a call installed its binding or found one already registered. + */ + private const array METHODS = ['bind' => true, 'singleton' => true, 'scoped' => true]; + + /** + * Create a new binding cycle rule instance. + */ + public function __construct( + private RuntimeInputs $runtimeInputs, + private WorkerStateHelper $workerStateHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report a binding that resolves itself. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! ($node instanceof MethodCall || $node instanceof StaticCall) + || ! $node->name instanceof Identifier + || ! isset(self::METHODS[$node->name->toLowerString()])) { + return []; + } + + $abstract = $node->getArg('abstract', 0); + $concrete = $node->getArg('concrete', 1); + + if ($abstract === null || $concrete === null || ! $this->bindsOnApplication($node, $scope)) { + return []; + } + + $abstracts = $scope->getType($abstract->value)->getConstantStrings(); + $concretes = $scope->getType($concrete->value)->getConstantStrings(); + + if (count($abstracts) !== 1 || count($concretes) !== 1 || $abstracts[0]->getValue() === $concretes[0]->getValue()) { + return []; + } + + // The booted aliases decide where the concrete resolves. + $this->runtimeInputs->track($scope); + + // The analysis always runs on the framework container: the booted application or a test's own. + /** @var Container $container */ + $container = $this->getContainer(); + + if ($container->getAlias($concretes[0]->getValue()) !== $abstracts[0]->getValue()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + "%s() binds '%s' to %s, which is an alias of '%s', so resolving it never ends.", + $node->name->toString(), + $abstracts[0]->getValue(), + $concretes[0]->getValue(), + $abstracts[0]->getValue(), + )) + ->identifier('hypervel.bindingCycle') + ->tip(sprintf('Bind a closure that creates %s with new instead of its class name.', class_basename($concretes[0]->getValue()))) + ->build(), + ]; + } + + /** + * Determine whether the call runs the framework's binding method on the application container. + */ + private function bindsOnApplication(MethodCall|StaticCall $call, Scope $scope): bool + { + if ($call instanceof MethodCall) { + return $this->workerStateHelper->isApplicationContainerCall($call, $scope); + } + + if (! $call->class instanceof Name || ! $call->name instanceof Identifier) { + return false; + } + + $facade = new ObjectType($scope->resolveName($call->class)); + + // A facade subclass's own method is an override, whose body is unknown. + return (new ObjectType(App::class))->isSuperTypeOf($facade)->yes() + && $scope->getMethodReflection($facade, $call->name->toString())?->getDeclaringClass()->getName() === App::class; + } +} diff --git a/src/phpstan/src/Rules/ConfigCollectionRule.php b/src/phpstan/src/Rules/ConfigCollectionRule.php index d9a5e8308..44189d631 100644 --- a/src/phpstan/src/Rules/ConfigCollectionRule.php +++ b/src/phpstan/src/Rules/ConfigCollectionRule.php @@ -66,7 +66,7 @@ public function processNode(Node $node, Scope $scope): array return [ RuleErrorBuilder::message(sprintf( 'Config key %s is not an array, so collection() throws. Correct the config value or use the getter that matches its type.', - $scope->getType($key->value)->describe(VerbosityLevel::value()), + $scope->getType($key->value)->describe(VerbosityLevel::precise()), )) ->identifier('hypervel.configCollection') ->build(), diff --git a/src/phpstan/src/Rules/ConfigTypeRule.php b/src/phpstan/src/Rules/ConfigTypeRule.php index 60891c8b0..da4da2e5b 100644 --- a/src/phpstan/src/Rules/ConfigTypeRule.php +++ b/src/phpstan/src/Rules/ConfigTypeRule.php @@ -79,7 +79,7 @@ public function processNode(Node $node, Scope $scope): array return [ RuleErrorBuilder::message(sprintf( 'Config key %s is not %s, so %s() throws. Correct the config value or use the getter that matches its type.', - $scope->getType($key->value)->describe(VerbosityLevel::value()), + $scope->getType($key->value)->describe(VerbosityLevel::precise()), self::GETTER_TYPES[$methodName], $methodName, )) diff --git a/src/phpstan/src/Rules/Coroutine/BorrowedResourceEscapeRule.php b/src/phpstan/src/Rules/Coroutine/BorrowedResourceEscapeRule.php new file mode 100644 index 000000000..e996b07f8 --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/BorrowedResourceEscapeRule.php @@ -0,0 +1,2507 @@ +} + * @phpstan-type Environment array{tracked: array, appended: array, callables: array>, referenced: array, globals: array, byReference: array, nonNull: array, nonEmpty: array} + * @phpstan-type Fact array{tracked: ?string, appended: ?string, callables: array} + * @phpstan-type Flow array{normal: ?Environment, breaks: array, continues: array, exit: ?Environment} + * @phpstan-type Walk array{borrow: array{method: string, resource: string}, owners: array, locals: array, external: ?array, outer: ?array, writes: array>, positions: array>, loops: list, isCallback: bool, generator: bool, used: ?string, catching: bool, thrown: ?Environment, created: array, spawned: array, errors: array} + * + * @implements Rule + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkBorrowedResources%')] +class BorrowedResourceEscapeRule implements Rule +{ + use InspectsConnections; + + /** + * The lowercase names of the lending methods below. + */ + private const array LENDING_METHODS = [ + 'withadapter' => true, + 'withclient' => true, + 'withconnection' => true, + 'withdriver' => true, + 'withpinnedsession' => true, + ]; + + /** + * The resource each lending method lends its callback, by declaring class and lowercase method. + */ + private const array LENDERS = [ + RedisProxy::class => ['withconnection' => 'redis'], + Redis::class => ['withconnection' => 'redis'], + StoreContext::class => ['withconnection' => 'redis'], + QueuePoolProxy::class => ['withconnection' => 'sqs'], + // The classes using InteractsWithPooledFilesystem; an unpooled disk lends its own driver. + FilesystemPoolProxy::class => ['withdriver' => 'driver', 'withadapter' => 'adapter', 'withclient' => 'client'], + ClientPooledFilesystem::class => ['withdriver' => 'driver', 'withadapter' => 'adapter', 'withclient' => 'client'], + Connection::class => ['withpinnedsession' => 'session'], + DB::class => ['withpinnedsession' => 'session'], + ]; + + /** + * Each resource's name, and the names of the raw handles its lowercase methods take from it. + * + * A pinned session lends nothing, but keeps the PDOs taken from its connection on the pinned session. + */ + private const array RESOURCES = [ + 'redis' => ['Redis connection', ['client' => 'Redis client']], + 'sqs' => ['SQS queue', ['getsqs' => 'SQS client']], + 'driver' => ['Flysystem driver', []], + 'adapter' => ['Flysystem adapter', []], + 'client' => ['filesystem client', []], + 'session' => [null, ['getpdo' => 'PDO', 'getreadpdo' => 'read PDO']], + ]; + + /** + * What a value that holds nothing borrowed is. + */ + private const array NO_FACT = ['tracked' => null, 'appended' => null, 'callables' => []]; + + /** + * The functions that run their callbacks in a coroutine or later, so the callbacks can outlive the caller. + */ + private const array SPAWNING_FUNCTIONS = [ + 'defer' => true, + 'hypervel\coroutine\co' => true, + 'hypervel\coroutine\go' => true, + 'hypervel\coroutine\parallel' => true, + 'hypervel\coroutine\wait' => true, + 'hypervel\support\defer' => true, + 'swoole\coroutine\defer' => true, + 'swoole\coroutine\go' => true, + ]; + + /** + * The lowercase methods that run their callbacks in a coroutine or later, by exact declaring class. + * + * Parallel cancels its children with its owner but does not join them, so a child whose cleanup + * yields can still run after the callback returns. The Concurrency driver is configuration, so + * only an explicit sync driver's run() keeps its tasks in place. + */ + private const array SPAWNING_METHODS = [ + Coroutine::class => ['create', 'createowned', 'fork', 'forkowned', 'defer'], + EngineCoroutine::class => ['create', 'defer'], + SwooleCoroutine::class => ['create', 'defer'], + Parallel::class => ['add'], + Waiter::class => ['wait'], + Concurrent::class => ['create', 'fork'], + WaitConcurrent::class => ['create', 'fork'], + Concurrency::class => ['run', 'defer'], + Driver::class => ['run', 'defer'], + CoroutineDriver::class => ['run', 'defer'], + ProcessDriver::class => ['run', 'defer'], + SyncDriver::class => ['defer'], + Timer::class => ['after', 'tick', 'until'], + ]; + + /** + * Create a new borrowed resource escape rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report the escapes from a lending method's callback. + * + * @param CallLike $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + // PHPStan also analyzes a nullsafe call as a method call. + if (! ($node instanceof MethodCall || $node instanceof StaticCall) + || ! $node->name instanceof Identifier + || ! isset(self::LENDING_METHODS[$node->name->toLowerString()])) { + return []; + } + + $callback = $node->getArg('callback', 0)?->value; + $lender = $this->findLender($node, $node->name->toString(), $scope); + + if ((! $callback instanceof Closure && ! $callback instanceof ArrowFunction) || $lender === null) { + return []; + } + + [$method, $resource] = $lender; + $owners = []; + + if ($resource === 'session') { + $owner = $this->getOwner($node, $scope); + + if ($owner === null) { + return []; + } + + $tracked = []; + $owners = $this->getCaptured($callback, [$owner => true]); + } else { + $parameter = $callback->params[0]->var ?? null; + + if (! $parameter instanceof Variable || ! is_string($parameter->name)) { + return []; + } + + $tracked = [$parameter->name => 'lent']; + } + + $errors = []; + $borrow = ['method' => $method, 'resource' => $resource]; + $environment = [ + 'tracked' => $tracked, + 'appended' => [], + 'callables' => [], + 'referenced' => [], + 'globals' => [], + 'byReference' => $callback instanceof Closure ? $this->getReferenceNames($callback) : [], + 'nonNull' => [], + 'nonEmpty' => [], + ]; + + // A closure callback receives its `use` variables from outside; an arrow function every variable it reads. + $this->analyzeFunction( + $callback, + $borrow, + $environment, + $owners, + $callback instanceof Closure ? $this->getUseNames($callback) : null, + true, + $scope, + $errors, + ); + + return array_values($errors); + } + + /** + * Find the lending method the call runs and the resource it lends, or null when it is not one. + * + * @return null|array{string, string} + */ + private function findLender(MethodCall|StaticCall $call, string $name, Scope $scope): ?array + { + $classNames = match (true) { + $call instanceof MethodCall => $scope->getType($call->var)->getObjectClassNames(), + $call->class instanceof Name => [$scope->resolveName($call->class)], + default => [], + }; + + $lender = null; + + foreach ($classNames as $className) { + if (! $this->reflectionProvider->hasClass($className) || ! $this->reflectionProvider->getClass($className)->hasMethod($name)) { + return null; + } + + // A user override or another implementation is judged by its own declaration. + $method = $this->reflectionProvider->getClass($className)->getMethod($name, $scope); + $resource = self::LENDERS[$method->getDeclaringClass()->getName()][strtolower($name)] ?? null; + + if ($resource === null || ($lender !== null && $lender[1] !== $resource)) { + return null; + } + + $lender = [$method->getName(), $resource]; + } + + return $lender; + } + + /** + * Get the variable or `$this` property holding the pinned connection, or the DB facade class for its + * default connection. + */ + private function getOwner(MethodCall|StaticCall $call, Scope $scope): ?string + { + if ($call instanceof StaticCall) { + return $call->class instanceof Name && ReflectionHelper::isFacade($call->class, DB::class, 'DB') ? DB::class : null; + } + + return $call->var instanceof Variable && is_string($call->var->name) ? $call->var->name : $this->getPropertyKey($call->var, $scope); + } + + /** + * Report the escapes in the callback, or in a closure nested in it. + * + * Only the callback's own returns and by-reference variables leave it; a nested closure returns + * to its caller inside the callback. The body is walked in statement order: each branch from what + * reaches it, joined where the branches meet, and each loop's first iteration from what reaches it + * and its later ones from what earlier ones pass back. A join keeps whatever any path gives a variable. + * + * @param array{method: string, resource: string} $borrow + * @param Environment $environment what the function receives: each borrowed variable's value ("lent", or the name of the raw handle), what its arrays hold, the closures each variable may hold, and the variables bound to global or the caller's variables + * @param array $owners the variables or `$this` properties holding the pinned connection the function receives, or the DB facade + * @param null|array $received the variables the function receives from outside the callback, or null for every variable it doesn't own + * @param array $errors + */ + private function analyzeFunction( + Closure|ArrowFunction $function, + array $borrow, + array $environment, + array $owners, + ?array $received, + bool $isCallback, + Scope $scope, + array &$errors, + ): void { + $nodes = $this->getOwnNodes($function instanceof Closure ? $function->stmts : [$function->expr]); + [$writes, $propertyWrites, $positions] = $this->collectWrites($nodes); + + // A reassigned variable or property no longer holds the pinned connection. + $owners = array_diff_key($owners, $writes, $propertyWrites); + $locals = $writes + $this->getParameterNames($function); + + // The variables still holding what they received from outside the callback. An object they hold + // outlives the callback, so a property written through one keeps its value. + $external = $received === null ? null : array_diff_key($received, $locals); + + $loops = []; + + foreach ($nodes as $node) { + if ($node instanceof Foreach_ || $node instanceof For_ || $node instanceof While_ || $node instanceof Do_) { + $loops[] = [$node->getStartFilePos(), $node->getEndFilePos()]; + } + } + + $walk = [ + 'borrow' => $borrow, + 'owners' => $owners, + 'locals' => $locals, + 'external' => $external, + // The callback's external variables are unchanged from the lending call's scope, which knows their + // types. A nested function's are unknown. + 'outer' => $isCallback ? $external : [], + 'writes' => $writes, + 'positions' => $positions, + 'loops' => $loops, + 'isCallback' => $isCallback, + // Calling a generator returns it before its body runs, so the lending method ends the borrow before the + // body uses anything borrowed. + 'generator' => $isCallback && array_any($nodes, static fn (Node $node): bool => $node instanceof Yield_ || $node instanceof YieldFrom), + 'used' => null, + 'catching' => false, + 'thrown' => null, + 'created' => [], + 'spawned' => [], + 'errors' => [], + ]; + + if ($function instanceof Closure) { + $this->walkStatements($function->stmts, $environment, $walk, $scope); + } else { + [$after, $fact] = $this->evaluate($function->expr, $environment, $walk, $scope); + $kind = $isCallback && $after !== null ? $this->getKind($fact, $after) : null; + + if ($kind !== null) { + $this->addError($walk['errors'], $function->expr, $borrow, $kind, 'is returned'); + } + } + + if ($walk['used'] !== null) { + $this->addError($walk['errors'], $function, $borrow, $walk['used'], 'is used in a generator callback'); + } + + $errors += $walk['errors']; + + foreach ($walk['created'] as $id => [$node, $created, $position]) { + if ($walk['spawned'][$id] ?? false) { + continue; + } + + $nestedOwners = $this->getCaptured($node, $owners); + + // A spawned closure runs in a child coroutine, which resolves its own default connection because copied + // contexts leave connections out, or in a deferred callback after the pin ends. A connection object it + // captures is still the pinned one. + if (isset($walk['spawned'][$id])) { + unset($nestedOwners[DB::class]); + } + + $references = $node instanceof Closure ? $this->getReferenceNames($node) : []; + + // A variable captured by reference holds what it was last given when the nested function runs, so + // a write after the function is created, or in a loop around its creation, leaves its value unknown. + $rewritten = array_filter( + $references, + fn (string $name): bool => $this->isRewritten($name, $position, $walk), + ARRAY_FILTER_USE_KEY, + ); + + // A nested function takes from outside the callback what this function itself took from there, and what + // a global or by-reference name held when it was created, which the callback doesn't own even when + // captured by value. Only a by-reference `use` shares the binding itself, so assigning the name writes there. + $captured = $this->getCapturedNames($node); + + $this->analyzeFunction( + $node, + $borrow, + [ + 'tracked' => array_diff_key($this->getCaptured($node, $created['tracked']), $rewritten), + 'appended' => array_diff_key($this->getCaptured($node, $created['appended']), $rewritten), + 'callables' => array_diff_key($this->getCarriedCallables($node, $created, $position, $walk), $rewritten), + 'referenced' => [], + 'globals' => array_intersect_key($references, $created['globals']), + 'byReference' => array_intersect_key($references, $created['byReference']), + 'nonNull' => [], + 'nonEmpty' => [], + ], + $nestedOwners, + ($external === null ? array_diff_key($captured, $locals) : array_intersect_key($captured, $external)) + + array_intersect_key($captured, $created['byReference'] + $created['globals']), + false, + $scope, + $errors, + ); + } + } + + /** + * Walk statements in order from what reaches them, stopping where no path goes on. + * + * @param array $statements + * @param Environment $environment + * @param Walk $walk + * + * @return Flow + */ + private function walkStatements(array $statements, array $environment, array &$walk, Scope $scope): array + { + $flow = $this->flow($environment); + + foreach ($statements as $statement) { + if ($flow['normal'] === null) { + break; + } + + $flow = $this->joinFlows(['normal' => null] + $flow, $this->walkStatement($statement, $flow['normal'], $walk, $scope)); + } + + return $flow; + } + + /** + * Walk a statement from what reaches it. + * + * @param Environment $environment + * @param Walk $walk + * + * @return Flow + */ + private function walkStatement(Stmt $statement, array $environment, array &$walk, Scope $scope): array + { + return match (true) { + $statement instanceof Expression => $this->flow($this->evaluate($statement->expr, $environment, $walk, $scope)[0]), + $statement instanceof Echo_ => $this->flow($this->evaluateAll($statement->exprs, $environment, $walk, $scope)), + $statement instanceof Return_ => $this->walkReturn($statement, $environment, $walk, $scope), + $statement instanceof If_ => $this->walkIf($statement, $environment, $walk, $scope), + $statement instanceof Switch_ => $this->walkSwitch($statement, $environment, $walk, $scope), + $statement instanceof While_, $statement instanceof Do_, $statement instanceof For_, $statement instanceof Foreach_ => $this->walkLoop($statement, $environment, $walk, $scope), + $statement instanceof TryCatch => $this->walkTry($statement, $environment, $walk, $scope), + $statement instanceof Break_ => ['normal' => null, 'breaks' => [$this->getLevel($statement) => $environment], 'continues' => [], 'exit' => null], + $statement instanceof Continue_ => ['normal' => null, 'breaks' => [], 'continues' => [$this->getLevel($statement) => $environment], 'exit' => null], + $statement instanceof Unset_ => $this->flow($this->walkUnset($statement, $environment, $walk, $scope)), + $statement instanceof Static_ => $this->flow($this->walkStatic($statement, $environment, $walk, $scope)), + $statement instanceof Global_ => $this->flow($this->detach($environment, $this->getGlobalNames($statement), 'global')), + $statement instanceof Block, $statement instanceof Declare_ => $this->walkStatements($statement->stmts ?? [], $environment, $walk, $scope), + // A goto isn't followed, so nothing after it is known to be reached. + $statement instanceof Goto_ => $this->flow(null), + default => $this->flow($environment), + }; + } + + /** + * Walk a return, which leaves the callback with its value. + * + * @param Environment $environment + * @param Walk $walk + * + * @return Flow + */ + private function walkReturn(Return_ $return, array $environment, array &$walk, Scope $scope): array + { + if ($return->expr !== null) { + [$environment, $fact] = $this->evaluate($return->expr, $environment, $walk, $scope); + $kind = $walk['isCallback'] && $environment !== null ? $this->getKind($fact, $environment) : null; + + if ($kind !== null) { + $this->addError($walk['errors'], $return, $walk['borrow'], $kind, 'is returned'); + } + } + + return ['normal' => null, 'breaks' => [], 'continues' => [], 'exit' => $environment]; + } + + /** + * Walk each branch of an if from what its condition leaves, and join them. + * + * @param Environment $environment + * @param Walk $walk + * + * @return Flow + */ + private function walkIf(If_ $if, array $environment, array &$walk, Scope $scope): array + { + [$condition] = $this->evaluate($if->cond, $environment, $walk, $scope); + + if ($condition === null) { + return $this->flow(null); + } + + $flow = $this->walkStatements($if->stmts, $condition, $walk, $scope); + + foreach ($if->elseifs as $elseif) { + [$condition] = $this->evaluate($elseif->cond, $condition, $walk, $scope); + + if ($condition === null) { + return $flow; + } + + $flow = $this->joinFlows($flow, $this->walkStatements($elseif->stmts, $condition, $walk, $scope)); + } + + return $this->joinFlows($flow, $if->else === null ? $this->flow($condition) : $this->walkStatements($if->else->stmts, $condition, $walk, $scope)); + } + + /** + * Walk each case of a switch from what its subject leaves, falling through into the next case. + * + * @param Environment $environment + * @param Walk $walk + * + * @return Flow + */ + private function walkSwitch(Switch_ $switch, array $environment, array &$walk, Scope $scope): array + { + [$tested] = $this->evaluate($switch->cond, $environment, $walk, $scope); + + // Each case is entered with what the comparisons up to its own leave, and the default, wherever it is, with + // what every comparison leaves. + $entries = []; + $default = null; + + foreach ($switch->cases as $index => $case) { + if ($case->cond === null) { + $default = $index; + } elseif ($tested !== null) { + [$tested] = $this->evaluate($case->cond, $tested, $walk, $scope); + $entries[$index] = $tested; + } + } + + if ($default !== null) { + $entries[$default] = $tested; + } + + $flow = $this->flow(null); + $fallthrough = null; + + foreach ($switch->cases as $index => $case) { + $entry = $this->join($entries[$index] ?? null, $fallthrough); + $caseFlow = $entry === null ? $this->flow(null) : $this->walkStatements($case->stmts, $entry, $walk, $scope); + $fallthrough = $caseFlow['normal']; + $flow = $this->joinFlows($flow, ['normal' => null] + $caseFlow); + } + + // Without a default no case may match, and the last case falls out of the switch. A switch counts as a loop for + // break and continue, and a continue that targets it acts like break. + return $this->leaveLevel($flow, $this->join( + $this->join($default === null ? $tested : null, $fallthrough), + $this->join($flow['breaks'][1] ?? null, $flow['continues'][1] ?? null), + )); + } + + /** + * Walk a loop: its first iteration from what reaches it, then its later iterations until what they pass back + * stops changing. + * + * A later iteration starts only from what an earlier one passes back, so it doesn't meet what the variables held + * before the loop. Each later pass only adds to what reaches the next, where a variable only becomes bound by + * reference once, every other fact can only be added once and what's known for certain can only be lost once, + * so the passes end. + * + * @param Environment $environment + * @param Walk $walk + * + * @return Flow + */ + private function walkLoop(While_|Do_|For_|Foreach_ $loop, array $environment, array &$walk, Scope $scope): array + { + $head = match (true) { + $loop instanceof For_ => $this->evaluateAll($loop->init, $environment, $walk, $scope), + $loop instanceof Foreach_ => $this->evaluate($loop->expr, $environment, $walk, $scope)[0], + default => $environment, + }; + + if ($head === null) { + return $this->flow(null); + } + + [$firstLeaves, $first, $later] = $this->walkIteration($loop, $head, $walk, $scope); + + // A foreach over an array literal holding an item that isn't unpacked, or over a variable last given one, runs + // at least once, so it never leaves before its first iteration. + $leaves = $loop instanceof Foreach_ && ($this->isNonEmptyArray($loop->expr) + || ($loop->expr instanceof Variable && is_string($loop->expr->name) && isset($head['nonEmpty'][$loop->expr->name]))) + ? null + : $firstLeaves; + $last = $this->flow(null); + + // An iteration that passes back what reached it makes every later one repeat the first. + if ($later === $head) { + $later = null; + $leaves = $firstLeaves; + } + + while ($later !== null) { + [$laterLeaves, $last, $back] = $this->walkIteration($loop, $later, $walk, $scope); + $next = $this->join($later, $back) ?? $later; + + if ($next === $later) { + $leaves = $this->join($leaves, $laterLeaves); + + break; + } + + $later = $next; + } + + $flow = $this->joinFlows($first, $last); + + return $this->leaveLevel($flow, $this->join($leaves, $flow['breaks'][1] ?? null)); + } + + /** + * Determine whether the expression is an array literal holding an item that isn't unpacked, which is never empty. + */ + private function isNonEmptyArray(Expr $expr): bool + { + return $expr instanceof Array_ && array_any($expr->items, static fn (ArrayItem $item): bool => ! $item->unpack); + } + + /** + * Walk one iteration of a loop from what reaches its head: where the loop may leave there, what its body does, + * and what the iteration passes back to the head. + * + * @param Environment $head + * @param Walk $walk + * + * @return array{?Environment, Flow, ?Environment} + */ + private function walkIteration(While_|Do_|For_|Foreach_ $loop, array $head, array &$walk, Scope $scope): array + { + if ($loop instanceof Do_) { + $body = $this->walkStatements($loop->stmts, $head, $walk, $scope); + $end = $this->join($body['normal'], $body['continues'][1] ?? null); + $back = $end === null ? null : $this->evaluate($loop->cond, $end, $walk, $scope)[0]; + + return [$this->isTrue($loop->cond) ? null : $back, $body, $back]; + } + + $entry = match (true) { + $loop instanceof While_ => $this->evaluate($loop->cond, $head, $walk, $scope)[0], + $loop instanceof For_ => $this->evaluateAll($loop->cond, $head, $walk, $scope), + default => $this->assignForeach($loop, $head, $walk, $scope), + }; + + // A foreach leaves at its head once it runs out of values; the others when their condition fails. + $leaves = match (true) { + $loop instanceof While_ => $this->isTrue($loop->cond) ? null : $entry, + $loop instanceof For_ => $loop->cond === [] || $this->isTrue($loop->cond[count($loop->cond) - 1]) ? null : $entry, + default => $head, + }; + + $body = $entry === null ? $this->flow(null) : $this->walkStatements($loop->stmts, $entry, $walk, $scope); + $back = $this->join($body['normal'], $body['continues'][1] ?? null); + + if ($loop instanceof For_ && $back !== null) { + $back = $this->evaluateAll($loop->loop, $back, $walk, $scope); + } + + return [$leaves, $body, $back]; + } + + /** + * Apply a foreach's writes at the head of each iteration: its value target, then its key target, as PHP assigns + * them. They get values the rule doesn't follow, and a by-reference value binds the target to an element of the + * iterated array. + * + * @param Environment $environment + * @param Walk $walk + * + * @return ?Environment + */ + private function assignForeach(Foreach_ $foreach, array $environment, array &$walk, Scope $scope): ?array + { + $environment = $this->assignTarget($foreach->valueVar, $foreach->byRef, $environment, [], $foreach, $walk, $scope); + + return $environment === null || $foreach->keyVar === null + ? $environment + : $this->assignTarget($foreach->keyVar, false, $environment, [], $foreach, $walk, $scope); + } + + /** + * Walk a try, its catches and its finally. + * + * The catches receive what the try block has at each call and throw in it, the operations followed as throwing, of + * any exception type; other operations that can throw, such as division, aren't. The finally runs on each way out of + * the try and its catches, including an exception either lets out, then continues that way unless it leaves itself, + * so an exception reaches the enclosing try's catches with what the finally leaves. + * + * @param Environment $environment + * @param Walk $walk + * + * @return Flow + */ + private function walkTry(TryCatch $try, array $environment, array &$walk, Scope $scope): array + { + [$outerCatching, $outerThrown] = [$walk['catching'], $walk['thrown']]; + $walk['catching'] = true; + $walk['thrown'] = null; + $flow = $this->walkStatements($try->stmts, $environment, $walk, $scope); + $thrown = $walk['thrown']; + + // An exception may match no catch unless one takes every Throwable, and a catch may throw, so both leave with + // what they had at the throw. A try block that can't throw runs no catch. + $catchesAll = array_any($try->catches, static fn (Catch_ $catch): bool => array_any( + $catch->types, + static fn (Name $type): bool => strtolower($scope->resolveName($type)) === 'throwable', + )); + $escaping = $catchesAll ? null : $thrown; + + foreach ($thrown === null ? [] : $try->catches as $catch) { + $entry = $thrown; + + if ($catch->var instanceof Variable && is_string($catch->var->name)) { + $this->setFact($entry, $catch->var->name, self::NO_FACT); + } + + $walk['thrown'] = null; + $flow = $this->joinFlows($flow, $this->walkStatements($catch->stmts, $entry, $walk, $scope)); + $escaping = $this->join($escaping, $walk['thrown']); + } + + $walk['catching'] = $outerCatching; + $walk['thrown'] = $outerThrown; + + // An exception that leaves reaches the enclosing try's catches after the finally runs. + if ($try->finally === null) { + if ($escaping !== null) { + $this->recordThrow($walk, $escaping); + } + + return $flow; + } + + $result = $this->flow(null); + $ways = [['normal', 0, $flow['normal']], ['exit', 0, $flow['exit']], ['exception', 0, $escaping]]; + + foreach ($flow['breaks'] as $level => $break) { + $ways[] = ['breaks', $level, $break]; + } + + foreach ($flow['continues'] as $level => $continue) { + $ways[] = ['continues', $level, $continue]; + } + + foreach ($ways as [$way, $level, $reaching]) { + if ($reaching === null) { + continue; + } + + $finally = $this->walkStatements($try->finally->stmts, $reaching, $walk, $scope); + $pending = $this->flow(null); + + if ($finally['normal'] !== null && $way === 'exception') { + $this->recordThrow($walk, $finally['normal']); + } elseif ($finally['normal'] !== null && ($way === 'normal' || $way === 'exit')) { + $pending[$way] = $finally['normal']; + } elseif ($finally['normal'] !== null) { + $pending[$way][$level] = $finally['normal']; + } + + $result = $this->joinFlows($this->joinFlows($result, ['normal' => null] + $finally), $pending); + } + + return $result; + } + + /** + * Get the flow of a path that goes on normally, or of none. + * + * @param ?Environment $environment + * + * @return Flow + */ + private function flow(?array $environment): array + { + return ['normal' => $environment, 'breaks' => [], 'continues' => [], 'exit' => null]; + } + + /** + * Join two flows, each way out with the same way out. + * + * @param Flow $flow + * @param Flow $other + * + * @return Flow + */ + private function joinFlows(array $flow, array $other): array + { + foreach (['breaks', 'continues'] as $way) { + foreach ($other[$way] as $level => $environment) { + $flow[$way][$level] = $this->join($flow[$way][$level] ?? null, $environment) ?? $environment; + } + } + + $flow['normal'] = $this->join($flow['normal'], $other['normal']); + $flow['exit'] = $this->join($flow['exit'], $other['exit']); + + return $flow; + } + + /** + * Record what a call or throw inside a try leaves for its catches and finally. + * + * @param Walk $walk + * @param Environment $environment + */ + private function recordThrow(array &$walk, array $environment): void + { + if ($walk['catching']) { + $walk['thrown'] = $this->join($walk['thrown'], $environment); + } + } + + /** + * Leave a loop or switch: what goes on after it, and the breaks and continues that target an enclosing one. + * + * @param Flow $flow + * @param ?Environment $after + * + * @return Flow + */ + private function leaveLevel(array $flow, ?array $after): array + { + $outer = ['normal' => $after, 'breaks' => [], 'continues' => [], 'exit' => $flow['exit']]; + + foreach (['breaks', 'continues'] as $way) { + foreach ($flow[$way] as $level => $environment) { + if ($level > 1) { + $outer[$way][$level - 1] = $environment; + } + } + } + + return $outer; + } + + /** + * Get how many enclosing loops and switches a break or continue leaves. + */ + private function getLevel(Break_|Continue_ $statement): int + { + return $statement->num instanceof Int_ ? $statement->num->value : 1; + } + + /** + * Determine whether a loop condition is the literal true, so the loop only leaves through a break. + */ + private function isTrue(Expr $condition): bool + { + return $condition instanceof ConstFetch && $condition->name->toLowerString() === 'true'; + } + + /** + * Get the nodes of a function's own body: closures and arrow functions are included but not entered, and named + * functions and classes are skipped, since they take nothing from the function they're declared in. + * + * @param array $nodes + * + * @return list + */ + private function getOwnNodes(array $nodes): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $found = []; + + public function enterNode(Node $node): ?int + { + if ($node instanceof ClassLike || $node instanceof Function_) { + return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + + $this->found[] = $node; + + return $node instanceof Closure || $node instanceof ArrowFunction ? NodeVisitor::DONT_TRAVERSE_CHILDREN : null; + } + }; + + (new NodeTraverser($visitor))->traverse($nodes); + + return $visitor->found; + } + + /** + * Collect the values each variable is assigned, null for a write whose value is not followed, the + * `$this` properties written, and where each variable's writes end. + * + * @param list $nodes + * + * @return array{array>, array, array>} + */ + private function collectWrites(array $nodes): array + { + $writes = []; + $propertyWrites = []; + $positions = []; + + foreach ($nodes as $node) { + foreach ($this->getUnknownTargets($node) as $leaf) { + if ($leaf instanceof Variable && is_string($leaf->name)) { + $writes[$leaf->name][] = null; + $positions[$leaf->name][] = $node->getEndFilePos(); + } elseif (($property = $this->getPropertyWriteKey($leaf)) !== null) { + $propertyWrites[$property] = true; + } + } + + // A later write through either side of a reference binding changes the other, so both are written. + $sides = $this->getReferenceSides($node); + + foreach ($this->getRootNames($sides) as $name) { + $writes[$name][] = null; + $positions[$name][] = $node->getEndFilePos(); + } + + if ($sides !== [] && ($property = $this->getPropertyWriteKey($sides[count($sides) - 1])) !== null) { + $propertyWrites[$property] = true; + } + + if (! $node instanceof Assign && ! $node instanceof AssignRef && ! $node instanceof CoalesceAssign) { + continue; + } + + if (($property = $this->getPropertyWriteKey($node->var)) !== null) { + $propertyWrites[$property] = true; + } + + if ($node->var instanceof Variable && is_string($node->var->name)) { + $writes[$node->var->name][] = $node->expr; + $positions[$node->var->name][] = $node->getEndFilePos(); + } + } + + return [$writes, $propertyWrites, $positions]; + } + + /** + * Get both sides of the reference bindings the node makes, the source last: `=&`, by-reference destructuring + * items and the destructured value, or a by-reference foreach value and the iterated expression. + * + * @return list + */ + private function getReferenceSides(Node $node): array + { + $targets = match (true) { + $node instanceof AssignRef => [$node->var], + $node instanceof Assign => CoroutineRuleHelper::getReferenceLeaves($node->var), + $node instanceof Foreach_ => $node->byRef ? [$node->valueVar] : CoroutineRuleHelper::getReferenceLeaves($node->valueVar), + default => [], + }; + + return $targets === [] ? [] : [...$targets, $node->expr]; + } + + /** + * Get the variables reference sides tie together: the root variable of each side, such as `$items` for + * `$items[0]` or `$holder` for `$holder->value`. + * + * @param list $sides + * + * @return list + */ + private function getRootNames(array $sides): array + { + $names = []; + + foreach ($sides as $side) { + while ($side instanceof ArrayDimFetch || $side instanceof PropertyFetch) { + $side = $side->var; + } + + if ($side instanceof Variable && is_string($side->name) && $side->name !== 'this') { + $names[] = $side->name; + } + } + + return $names; + } + + /** + * Get the targets a node writes without a value the rule follows: destructuring, compound assignments + * other than `??=`, foreach, catch, static and global variables. + * + * @return list + */ + private function getUnknownTargets(Node $node): array + { + $targets = match (true) { + $node instanceof Assign && ($node->var instanceof List_ || $node->var instanceof Array_) => [$node->var], + $node instanceof AssignOp && ! $node instanceof CoalesceAssign => [$node->var], + $node instanceof Foreach_ => [$node->keyVar, $node->valueVar], + $node instanceof Catch_ => [$node->var], + $node instanceof Static_ => array_map(static fn (StaticVar $variable): Variable => $variable->var, $node->vars), + $node instanceof Global_ => $node->vars, + default => [], + }; + + $leaves = []; + + foreach ($targets as $target) { + $leaves = [...$leaves, ...CoroutineRuleHelper::getTargetLeaves($target)]; + } + + return $leaves; + } + + /** + * Point the names at new bindings on this path: through unset(), static, global or a by-reference binding. + * + * A closure sharing an old binding by reference keeps it, and the names can no longer reach it, so the + * closure keeps what the binding holds now: its value joins the closure's fixed kind, and the closures it + * holds keep reading their own by-reference variables. A detached name read through those closures keeps + * its binding the same way. Earlier copies keep their own values. + * + * @param Environment $environment + * @param list $names + * @param 'global'|'reference'|'static'|'unset' $how + * + * @return Environment + */ + private function detach(array $environment, array $names, string $how): array + { + if ($names === []) { + return $environment; + } + + $detached = array_fill_keys($names, true); + $before = $environment; + + foreach ($before['callables'] as $variable => $closures) { + foreach ($closures as $id => [$closure, $kind, $live]) { + $pending = array_intersect_key($live, $detached); + + if ($pending === []) { + continue; + } + + $live = array_diff_key($live, $detached); + + // Each detached name is followed once, so closures sharing each other's bindings end. + $reached = []; + + while ($pending !== []) { + $reached += $pending; + $kind ??= $this->getFirstKind($pending, $before, live: false); + $next = []; + + foreach (array_intersect_key($before['callables'], $pending) as $held) { + foreach ($held as [, , $references]) { + $live += array_diff_key($references, $detached); + $next += array_intersect_key($references, $detached); + } + } + + $pending = array_diff_key($next, $reached); + } + + $environment['callables'][$variable][$id] = [$closure, $kind, $live]; + } + } + + // A global name is bound to a global variable from here on; the others to nothing outside the function. + return [ + 'tracked' => array_diff_key($environment['tracked'], $detached), + 'appended' => array_diff_key($environment['appended'], $detached), + 'callables' => array_diff_key($environment['callables'], $detached), + 'referenced' => array_diff_key($environment['referenced'], $detached), + 'globals' => $how === 'global' ? $environment['globals'] + $detached : array_diff_key($environment['globals'], $detached), + 'byReference' => array_diff_key($environment['byReference'], $detached), + 'nonNull' => array_diff_key($environment['nonNull'], $detached), + 'nonEmpty' => array_diff_key($environment['nonEmpty'], $detached), + ]; + } + + /** + * Make the variables a reference binding ties together unknown: a later write through either side changes both, + * so neither value is followed from here on, including on paths that join this one. + * + * @param list $sides + * @param Environment $environment + * + * @return Environment + */ + private function bindReferences(array $sides, array $environment): array + { + foreach ($this->getRootNames($sides) as $name) { + unset($environment['tracked'][$name], $environment['appended'][$name], $environment['callables'][$name], $environment['nonNull'][$name], $environment['nonEmpty'][$name]); + $environment['referenced'][$name] = true; + } + + return $environment; + } + + /** + * Apply an unset(): a plain variable gets a new, undefined binding, while unsetting an offset or property leaves + * what the rest of its array holds, though the array may now be empty. + * + * @param Environment $environment + * @param Walk $walk + * + * @return ?Environment + */ + private function walkUnset(Unset_ $unset, array $environment, array &$walk, Scope $scope): ?array + { + // PHP unsets left to right, so a later target sees what an earlier one did. + foreach ($unset->vars as $variable) { + if ($variable instanceof Variable && is_string($variable->name)) { + $environment = $this->detach($environment, [$variable->name], 'unset'); + + continue; + } + + $environment = $this->evaluateTarget($variable, $environment, $walk, $scope); + + if ($environment === null) { + return null; + } + + // Removing an offset changes the array without adding to it. + $environment = $this->write($environment, $variable, self::NO_FACT); + } + + return $environment; + } + + /** + * Apply a `static` declaration: each variable in turn gets its initializer, which only runs the first time the + * closure reaches it, then a binding of its own. + * + * @param Environment $environment + * @param Walk $walk + * + * @return Environment + */ + private function walkStatic(Static_ $static, array $environment, array &$walk, Scope $scope): array + { + foreach ($static->vars as $variable) { + if ($variable->default !== null) { + $environment = $this->join($environment, $this->evaluate($variable->default, $environment, $walk, $scope)[0]) ?? $environment; + } + + if (is_string($variable->var->name)) { + $environment = $this->detach($environment, [$variable->var->name], 'static'); + } + } + + return $environment; + } + + /** + * Get the names a `global` statement binds. + * + * @return list + */ + private function getGlobalNames(Global_ $global): array + { + $names = []; + + foreach ($global->vars as $variable) { + if ($variable instanceof Variable && is_string($variable->name)) { + $names[] = $variable->name; + } + } + + return $names; + } + + /** + * Join what two paths give each variable: whatever either gives it, except that a variable bound by + * reference on either path is unknown. What's known for certain holds only when both paths know it. + * + * @param ?Environment $environment + * @param ?Environment $other + * + * @return ?Environment + */ + private function join(?array $environment, ?array $other): ?array + { + if ($environment === null || $other === null || $environment === $other) { + return $environment ?? $other; + } + + $callables = $environment['callables']; + + foreach ($other['callables'] as $name => $closures) { + $callables[$name] = $this->mergeCallables($callables[$name] ?? [], $closures); + } + + $referenced = $environment['referenced'] + $other['referenced']; + + return [ + 'tracked' => array_diff_key($environment['tracked'] + $other['tracked'], $referenced), + 'appended' => array_diff_key($environment['appended'] + $other['appended'], $referenced), + 'callables' => array_diff_key($callables, $referenced), + 'referenced' => $referenced, + 'globals' => $environment['globals'] + $other['globals'], + 'byReference' => $environment['byReference'] + $other['byReference'], + 'nonNull' => array_intersect_key($environment['nonNull'], $other['nonNull']), + 'nonEmpty' => array_intersect_key($environment['nonEmpty'], $other['nonEmpty']), + ]; + } + + /** + * Merge the closures two values may hold. A closure both hold, from the same node, keeps the first borrowed + * kind either captured by value and every name either reads by reference. + * + * @param array $closures + * @param array $others + * + * @return array + */ + private function mergeCallables(array $closures, array $others): array + { + foreach ($others as $id => $held) { + if (! isset($closures[$id])) { + $closures[$id] = $held; + + continue; + } + + $closures[$id][1] ??= $held[1]; + $closures[$id][2] += $held[2]; + } + + return $closures; + } + + /** + * Join what two values may be. + * + * @param Fact $fact + * @param Fact $other + * + * @return Fact + */ + private function joinFacts(array $fact, array $other): array + { + return [ + 'tracked' => $fact['tracked'] ?? $other['tracked'], + 'appended' => $fact['appended'] ?? $other['appended'], + 'callables' => $this->mergeCallables($fact['callables'], $other['callables']), + ]; + } + + /** + * Replace what the variable holds with the fact. A variable bound by reference stays unknown. + * + * @param Environment $environment + * @param Fact $fact + */ + private function setFact(array &$environment, string $name, array $fact): void + { + unset($environment['nonNull'][$name], $environment['nonEmpty'][$name]); + + if (isset($environment['referenced'][$name])) { + return; + } + + if ($fact['tracked'] === null) { + unset($environment['tracked'][$name]); + } else { + $environment['tracked'][$name] = $fact['tracked']; + } + + if ($fact['appended'] === null) { + unset($environment['appended'][$name]); + } else { + $environment['appended'][$name] = $fact['appended']; + } + + if ($fact['callables'] === []) { + unset($environment['callables'][$name]); + } else { + $environment['callables'][$name] = $fact['callables']; + } + } + + /** + * Get what a variable holds. + * + * @param Environment $environment + * + * @return Fact + */ + private function getVariableFact(array $environment, string $name): array + { + return [ + 'tracked' => $environment['tracked'][$name] ?? null, + 'appended' => $environment['appended'][$name] ?? null, + 'callables' => $environment['callables'][$name] ?? [], + ]; + } + + /** + * Write a value to an assignment's target: a variable takes it, and an array written through an offset keeps + * what it held as well. Properties aren't followed. + * + * @param Environment $environment + * @param Fact $fact + * + * @return Environment + */ + private function write(array $environment, Expr $target, array $fact): array + { + $root = $target; + + while ($root instanceof ArrayDimFetch) { + $root = $root->var; + } + + if (! $root instanceof Variable || ! is_string($root->name) || $root->name === 'this') { + return $environment; + } + + $this->setFact($environment, $root->name, $root === $target ? $fact : [ + 'tracked' => $environment['tracked'][$root->name] ?? null, + 'appended' => $environment['appended'][$root->name] ?? $fact['tracked'] ?? $fact['appended'], + 'callables' => $this->mergeCallables($environment['callables'][$root->name] ?? [], $fact['callables']), + ]); + + return $environment; + } + + /** + * Evaluate an expression from what reaches it, checking its escapes and applying its writes. + * + * Only PHP's guaranteed sequencing is followed: an assignment's value before its write, and the left side or + * condition of `&&`, `||`, `??`, a ternary and a match before the operands that depend on it, each of which starts + * from what reaches it. Other operands are taken in source order, which approximates expressions whose result + * depends on the order of side effects in different operands. + * + * @param Environment $environment + * @param Walk $walk + * + * @return array{?Environment, Fact} what follows the expression, or null when it always throws or exits, and what its value is or holds + */ + private function evaluate(Expr $expr, array $environment, array &$walk, Scope $scope): array + { + if ($expr instanceof Variable) { + if (! is_string($expr->name)) { + return [$this->evaluate($expr->name, $environment, $walk, $scope)[0], self::NO_FACT]; + } + + $fact = $this->getVariableFact($environment, $expr->name); + + if ($walk['generator']) { + $walk['used'] ??= $this->getKind($fact, $environment); + } + + return [$environment, $fact]; + } + + if ($expr instanceof Assign && ($expr->var instanceof List_ || $expr->var instanceof Array_)) { + return $this->evaluateDestructuring($expr, $expr->var, $environment, $walk, $scope); + } + + if ($expr instanceof CoalesceAssign) { + $before = $this->evaluateTarget($expr->var, $environment, $walk, $scope); + + if ($before === null) { + return [null, self::NO_FACT]; + } + + // `??=` keeps a target that is already set without running its value, so the target holds either value + // afterwards. + $old = $expr->var instanceof Variable && is_string($expr->var->name) ? $this->getVariableFact($before, $expr->var->name) : self::NO_FACT; + + // A variable known to hold a value other than null never runs it. + if ($expr->var instanceof Variable && is_string($expr->var->name) && isset($before['nonNull'][$expr->var->name])) { + return [$before, $old]; + } + + [$after, $fact] = $this->evaluate($expr->expr, $before, $walk, $scope); + + if ($after === null) { + return [$before, $old]; + } + + $fact = $this->joinFacts($old, $fact); + $this->checkStore($expr->var, $fact, $after, $walk, $expr); + + return [$this->join($before, $this->write($after, $expr->var, $fact)), $fact]; + } + + if ($expr instanceof Assign || $expr instanceof AssignRef || $expr instanceof AssignOp) { + $before = $this->evaluateTarget($expr->var, $environment, $walk, $scope); + [$after, $fact] = $before === null ? [null, self::NO_FACT] : $this->evaluate($expr->expr, $before, $walk, $scope); + + if ($after === null) { + return [null, self::NO_FACT]; + } + + if ($expr instanceof Assign) { + $this->checkStore($expr->var, $fact, $after, $walk, $expr); + $after = $this->write($after, $expr->var, $fact); + + // A literal other than null gives the variable a value `??=` keeps, and a nonempty array literal gives it + // one a foreach runs over at least once. A call or a write through a reference forgets both. + if ($expr->var instanceof Variable && is_string($expr->var->name) && ! isset($after['referenced'][$expr->var->name]) + && ($expr->expr instanceof Scalar + || $expr->expr instanceof Array_ + || $expr->expr instanceof New_ + || $expr->expr instanceof Closure + || $expr->expr instanceof ArrowFunction + || ($expr->expr instanceof ConstFetch && in_array($expr->expr->name->toLowerString(), ['true', 'false'], true)))) { + $after['nonNull'][$expr->var->name] = true; + + if ($this->isNonEmptyArray($expr->expr)) { + $after['nonEmpty'][$expr->var->name] = true; + } + } + + return [$after, $fact]; + } + + if ($expr instanceof AssignRef) { + // A reference assignment between a plain variable and global or by-reference storage binds the variable + // to that storage, read before the variable is detached from what it was bound to. Binding a plain + // variable by reference points it at another value instead of storing through it. + $alias = CoroutineRuleHelper::getStorageAlias($expr, $after['globals'] + $after['byReference'] + ['GLOBALS' => true]); + $global = $alias !== null && ($alias[1] === 'GLOBALS' || isset($after['globals'][$alias[1]])); + $after = $this->detach($after, CoroutineRuleHelper::getDetachedNames($expr), 'reference'); + $this->checkStore($expr->var, $fact, $after, $walk, $expr); + + if ($alias !== null) { + $after[$global ? 'globals' : 'byReference'][$alias[0]] = true; + } + + return [$this->bindReferences([$expr->var, $expr->expr], $after), $fact]; + } + + if ($expr->var instanceof Variable && is_string($expr->var->name)) { + $this->setFact($after, $expr->var->name, self::NO_FACT); + } + + return [$after, self::NO_FACT]; + } + + if ($expr instanceof PreInc || $expr instanceof PreDec || $expr instanceof PostInc || $expr instanceof PostDec) { + $after = $this->evaluateTarget($expr->var, $environment, $walk, $scope); + + if ($after !== null && $expr->var instanceof Variable && is_string($expr->var->name)) { + $this->setFact($after, $expr->var->name, self::NO_FACT); + } + + return [$after, self::NO_FACT]; + } + + if ($expr instanceof Ternary || $expr instanceof Coalesce || $expr instanceof BooleanAnd || $expr instanceof BooleanOr || $expr instanceof LogicalAnd || $expr instanceof LogicalOr) { + [$first, $firstFact] = $this->evaluate($expr instanceof Ternary ? $expr->cond : $expr->left, $environment, $walk, $scope); + + if ($first === null) { + return [null, self::NO_FACT]; + } + + $branches = match (true) { + $expr instanceof Ternary => [$expr->if === null ? [$first, $firstFact] : $this->evaluate($expr->if, $first, $walk, $scope), $this->evaluate($expr->else, $first, $walk, $scope)], + $expr instanceof Coalesce => [[$first, $firstFact], $this->evaluate($expr->right, $first, $walk, $scope)], + default => [[$first, self::NO_FACT], [$this->evaluate($expr->right, $first, $walk, $scope)[0], self::NO_FACT]], + }; + + return $this->joinBranches($branches); + } + + if ($expr instanceof Match_) { + [$tested] = $this->evaluate($expr->cond, $environment, $walk, $scope); + $branches = []; + $default = null; + + // Each arm runs with what any of its own conditions leaves, and the default arm, wherever it is, with what + // every condition leaves. + foreach ($expr->arms as $arm) { + if ($arm->conds === null) { + $default = $arm; + + continue; + } + + $matched = null; + + foreach ($arm->conds as $condition) { + if ($tested !== null) { + [$tested] = $this->evaluate($condition, $tested, $walk, $scope); + $matched = $this->join($matched, $tested); + } + } + + if ($matched !== null) { + $branches[] = $this->evaluate($arm->body, $matched, $walk, $scope); + } + } + + if ($default !== null && $tested !== null) { + $branches[] = $this->evaluate($default->body, $tested, $walk, $scope); + } + + return $this->joinBranches($branches); + } + + if ($expr instanceof Closure || $expr instanceof ArrowFunction) { + // A closure reached more than once, such as in a loop or a finally, captures what any of those visits gives it. + $id = spl_object_id($expr); + $walk['created'][$id] = [$expr, $this->join($walk['created'][$id][1] ?? null, $environment) ?? $environment, $expr->getStartFilePos()]; + $held = $this->getHeldClosure($expr, $environment); + + if ($walk['generator']) { + $walk['used'] ??= $held[1] ?? $this->getLiveKind($held, $environment); + } + + return [$environment, ['tracked' => null, 'appended' => null, 'callables' => [$id => $held]]]; + } + + if ($expr instanceof Array_) { + $fact = self::NO_FACT; + + foreach ($expr->items as $item) { + [$after, $itemFact] = $this->evaluateItem($item, $environment, $walk, $scope); + + if ($after === null) { + return [null, self::NO_FACT]; + } + + $environment = $after; + $fact['tracked'] ??= $itemFact['tracked'] ?? $itemFact['appended']; + $fact['callables'] = $this->mergeCallables($fact['callables'], $itemFact['callables']); + } + + return [$environment, $fact]; + } + + if ($expr instanceof CallLike) { + return $this->evaluateCall($expr, $environment, $walk, $scope); + } + + if ($expr instanceof Throw_) { + [$after] = $this->evaluate($expr->expr, $environment, $walk, $scope); + + if ($after !== null) { + $this->recordThrow($walk, $after); + } + + return [null, self::NO_FACT]; + } + + if ($expr instanceof Exit_) { + if ($expr->expr !== null) { + $this->evaluate($expr->expr, $environment, $walk, $scope); + } + + return [null, self::NO_FACT]; + } + + if ($expr instanceof ErrorSuppress) { + return $this->evaluate($expr->expr, $environment, $walk, $scope); + } + + // Any other expression reads its operands in source order, and its value isn't followed. + foreach ($expr->getSubNodeNames() as $name) { + foreach (is_array($expr->{$name}) ? $expr->{$name} : [$expr->{$name}] as $child) { + $child = $child instanceof Arg ? $child->value : $child; + + if ($child instanceof ArrayItem) { + [$environment] = $this->evaluateItem($child, $environment, $walk, $scope); + } elseif ($child instanceof Expr) { + [$environment] = $this->evaluate($child, $environment, $walk, $scope); + } + + if ($environment === null) { + return [null, self::NO_FACT]; + } + } + } + + return [$environment, self::NO_FACT]; + } + + /** + * Join the branches of a conditional expression that complete. + * + * @param list $branches + * + * @return array{?Environment, Fact} + */ + private function joinBranches(array $branches): array + { + $environment = null; + $fact = self::NO_FACT; + + foreach ($branches as [$after, $branchFact]) { + if ($after !== null) { + $environment = $this->join($environment, $after); + $fact = $this->joinFacts($fact, $branchFact); + } + } + + return [$environment, $fact]; + } + + /** + * Evaluate an array item: its key, then its value. + * + * @param Environment $environment + * @param Walk $walk + * + * @return array{?Environment, Fact} + */ + private function evaluateItem(ArrayItem $item, array $environment, array &$walk, Scope $scope): array + { + if ($item->key !== null) { + [$after] = $this->evaluate($item->key, $environment, $walk, $scope); + + if ($after === null) { + return [null, self::NO_FACT]; + } + + $environment = $after; + } + + return $this->evaluate($item->value, $environment, $walk, $scope); + } + + /** + * Evaluate a call: its receiver, then its arguments, then whether it spawns closures that can outlive the + * callback. Its result is a raw handle when it takes one from the lent resource. + * + * @param Environment $environment + * @param Walk $walk + * + * @return array{?Environment, Fact} + */ + private function evaluateCall(CallLike $call, array $environment, array &$walk, Scope $scope): array + { + $receiver = self::NO_FACT; + + if ($call instanceof MethodCall || $call instanceof NullsafeMethodCall) { + [$after, $receiver] = $this->evaluate($call->var, $environment, $walk, $scope); + } else { + $callee = match (true) { + $call instanceof FuncCall => $call->name, + $call instanceof StaticCall, $call instanceof New_ => $call->class, + default => null, + }; + + [$after] = $callee instanceof Expr ? $this->evaluate($callee, $environment, $walk, $scope) : [$environment]; + } + + // A nullsafe call's name, arguments and invocation don't run when its receiver is null. + $skipped = $call instanceof NullsafeMethodCall ? $after : null; + $passed = []; + + if (($call instanceof MethodCall || $call instanceof NullsafeMethodCall || $call instanceof StaticCall) && $call->name instanceof Expr && $after !== null) { + [$after] = $this->evaluate($call->name, $after, $walk, $scope); + } + + foreach ($call->isFirstClassCallable() ? [] : $call->getArgs() as $argument) { + if ($after === null) { + break; + } + + [$after, $argumentFact] = $this->evaluate($argument->value, $after, $walk, $scope); + $passed = $this->mergeCallables($passed, $argumentFact['callables']); + } + + if ($after === null) { + return [$skipped, self::NO_FACT]; + } + + $spawn = $this->getSpawn($call, $walk['locals'], $walk['writes'], $walk['outer'], $scope); + + if ($spawn !== null) { + $kind = null; + + foreach ($passed as $id => $held) { + $heldKind = $held[1] ?? $this->getLiveKind($held, $after); + $walk['spawned'][$id] = ($walk['spawned'][$id] ?? false) || $heldKind !== null; + $kind ??= $heldKind; + } + + if ($kind !== null) { + $this->addError($walk['errors'], $call, $walk['borrow'], $kind, sprintf('is captured by a closure passed to %s()', $spawn)); + } + } + + // Creating a first-class callable runs nothing. A call can change locals through references it's given or a + // closure it runs holds, and other coroutines can change by-reference and global variables while it waits, so + // what's known for certain about variables no longer holds. + if (! $call->isFirstClassCallable()) { + $after['nonNull'] = []; + $after['nonEmpty'] = []; + $this->recordThrow($walk, $after); + } + + $handle = $this->getHandleKind($call, $receiver, $walk['owners'], $walk['borrow']['resource'], $scope); + + if ($walk['generator']) { + $walk['used'] ??= $handle; + } + + return [$this->join($skipped, $after) ?? $after, ['tracked' => $handle, 'appended' => null, 'callables' => []]]; + } + + /** + * Evaluate a destructuring assignment: the value, then each target in order. A keyless array literal gives each + * keyless leaf the item at its position, whose store is checked; the leaves themselves get values the rule doesn't + * follow. + * + * @param Environment $environment + * @param Walk $walk + * + * @return array{?Environment, Fact} + */ + private function evaluateDestructuring(Assign $assign, List_|Array_ $target, array $environment, array &$walk, Scope $scope): array + { + $facts = []; + [$after, $fact] = $this->evaluateDestructured($target, $assign->expr, $environment, $walk, $scope, $facts); + $after = $after === null ? null : $this->assignTarget($target, false, $after, $facts, $assign, $walk, $scope); + + return $after === null ? [null, self::NO_FACT] : [$after, $fact]; + } + + /** + * Evaluate a destructured value, collecting what a keyless array literal gives each keyless leaf at its position, + * through nested destructuring. + * + * @param Environment $environment + * @param Walk $walk + * @param array $facts the facts by leaf node + * + * @return array{?Environment, Fact} + */ + private function evaluateDestructured(List_|Array_ $target, Expr $value, array $environment, array &$walk, Scope $scope, array &$facts): array + { + if (! $value instanceof Array_ || ! array_all($value->items, static fn (ArrayItem $item): bool => $item->key === null && ! $item->unpack)) { + return $this->evaluate($value, $environment, $walk, $scope); + } + + $fact = self::NO_FACT; + + foreach ($value->items as $position => $item) { + $leaf = $target->items[$position] ?? null; + + if ($leaf !== null && $leaf->key === null && ($leaf->value instanceof List_ || $leaf->value instanceof Array_)) { + [$after, $itemFact] = $this->evaluateDestructured($leaf->value, $item->value, $environment, $walk, $scope, $facts); + } else { + [$after, $itemFact] = $this->evaluate($item->value, $environment, $walk, $scope); + + if ($leaf !== null && $leaf->key === null) { + $facts[spl_object_id($leaf->value)] = $itemFact; + } + } + + if ($after === null) { + return [null, self::NO_FACT]; + } + + $environment = $after; + $fact['tracked'] ??= $itemFact['tracked'] ?? $itemFact['appended']; + $fact['callables'] = $this->mergeCallables($fact['callables'], $itemFact['callables']); + } + + return [$environment, $fact]; + } + + /** + * Assign a destructuring or foreach target in PHP's order: a list item by item, each item's key and then its own + * target, and a leaf once its offsets and objects are evaluated. A leaf a keyless array literal gives a value has + * its store checked against it, then gets a value the rule doesn't follow; a by-reference leaf is bound to an + * element of the assigned or iterated value. + * + * @param Environment $environment + * @param array $facts what a keyless array literal gives each leaf, by leaf node + * @param Walk $walk + * + * @return ?Environment + */ + private function assignTarget(Expr $target, bool $byReference, array $environment, array $facts, Assign|Foreach_ $node, array &$walk, Scope $scope): ?array + { + if ($target instanceof List_ || $target instanceof Array_) { + foreach ($target->items as $item) { + if ($item === null) { + continue; + } + + if ($item->key !== null) { + [$environment] = $this->evaluate($item->key, $environment, $walk, $scope); + } + + $environment = $environment === null ? null : $this->assignTarget($item->value, $item->byRef, $environment, $facts, $node, $walk, $scope); + + if ($environment === null) { + return null; + } + } + + return $environment; + } + + $environment = $this->evaluateTarget($target, $environment, $walk, $scope); + + if ($environment === null) { + return null; + } + + if ($byReference) { + if ($target instanceof Variable && is_string($target->name)) { + $environment = $this->detach($environment, [$target->name], 'reference'); + } + + return $this->bindReferences([$target, $node->expr], $environment); + } + + if (isset($facts[spl_object_id($target)])) { + $this->checkStore($target, $facts[spl_object_id($target)], $environment, $walk, $node); + } + + return $this->write($environment, $target, self::NO_FACT); + } + + /** + * Evaluate what a write target reads before it's written: its offsets and the objects whose properties it writes. + * + * @param Environment $environment + * @param Walk $walk + * + * @return ?Environment + */ + private function evaluateTarget(Expr $target, array $environment, array &$walk, Scope $scope): ?array + { + if ($target instanceof Variable) { + return is_string($target->name) ? $environment : $this->evaluate($target->name, $environment, $walk, $scope)[0]; + } + + if (! $target instanceof ArrayDimFetch) { + return $this->evaluate($target, $environment, $walk, $scope)[0]; + } + + $after = $this->evaluateTarget($target->var, $environment, $walk, $scope); + + return $after === null || $target->dim === null ? $after : $this->evaluate($target->dim, $after, $walk, $scope)[0]; + } + + /** + * Evaluate expressions in order. + * + * @param array $exprs + * @param Environment $environment + * @param Walk $walk + * + * @return ?Environment + */ + private function evaluateAll(array $exprs, array $environment, array &$walk, Scope $scope): ?array + { + foreach ($exprs as $expr) { + [$after] = $this->evaluate($expr, $environment, $walk, $scope); + + if ($after === null) { + return null; + } + + $environment = $after; + } + + return $environment; + } + + /** + * Get the raw handle a call takes from the lent resource, or from the pinned connection, or null. + * + * @param Fact $receiver what the call's receiver is or holds + * @param array $owners + */ + private function getHandleKind(CallLike $call, array $receiver, array $owners, string $resource, Scope $scope): ?string + { + if (! ($call instanceof MethodCall || $call instanceof NullsafeMethodCall || $call instanceof StaticCall) + || ! $call->name instanceof Identifier + || $call->isFirstClassCallable()) { + return null; + } + + $handle = self::RESOURCES[$resource][1][$call->name->toLowerString()] ?? null; + + if ($handle === null) { + return null; + } + + if ($resource !== 'session') { + return ! $call instanceof StaticCall && $receiver['tracked'] === 'lent' ? $handle : null; + } + + // A pinned session's PDOs come from the pinned connection itself, through the framework's own getters. + $ownerType = match (true) { + $call instanceof StaticCall => isset($owners[DB::class]) && $call->class instanceof Name && ReflectionHelper::isFacade($call->class, DB::class, 'DB') + ? new ObjectType($scope->resolveName($call->class)) + : null, + $call->var instanceof Variable && is_string($call->var->name) && isset($owners[$call->var->name]) => $scope->getType($call->var), + ($property = $this->getPropertyWriteKey($call->var)) !== null && isset($owners[$property]) => $scope->getType($call->var), + default => null, + }; + + return $ownerType !== null && $this->runsConnectionMethod($ownerType, $call->name->toString(), $scope) ? $handle : null; + } + + /** + * Report a borrowed value an assignment stores where it outlives the callback. + * + * @param Fact $fact + * @param Environment $environment + * @param Walk $walk + */ + private function checkStore(Expr $target, array $fact, array $environment, array &$walk, Node $node): void + { + $how = $this->getStore($target, $environment['byReference'], $environment['globals'], $walk['external'], $walk['locals']); + $kind = $how === null ? null : $this->getKind($fact, $environment); + + if ($how !== null && $kind !== null) { + $this->addError($walk['errors'], $node, $walk['borrow'], $kind, $how); + } + } + + /** + * Get what a value is or holds now: "lent" for the lent resource, the name of a raw handle, or null. A closure + * holds what it captured by value and what it reads by reference now. + * + * @param Fact $fact + * @param Environment $environment + */ + private function getKind(array $fact, array $environment): ?string + { + if (($fact['tracked'] ?? $fact['appended']) !== null) { + return $fact['tracked'] ?? $fact['appended']; + } + + foreach ($fact['callables'] as $held) { + $kind = $held[1] ?? $this->getLiveKind($held, $environment); + + if ($kind !== null) { + return $kind; + } + } + + return null; + } + + /** + * Get how an assignment to the target keeps its value beyond the callback, or null when it stays local. + * + * A property of $this, or of an object a variable holds from outside the callback or from a global variable, + * outlives it, as do global variables. + * + * @param array $byReference the callback's by-reference variables + * @param array $globals the variables bound to global variables + * @param null|array $external the variables holding what they received from outside the callback, or null for every one the function doesn't own + * @param array $locals the function's parameters and assigned variables + */ + private function getStore(Expr $target, array $byReference, array $globals, ?array $external, array $locals): ?string + { + while ($target instanceof ArrayDimFetch || $target instanceof PropertyFetch) { + if ($target instanceof PropertyFetch + && $target->var instanceof Variable + && is_string($name = $target->var->name) + && ($name === 'this' || isset($globals[$name]) || ($external === null ? ! isset($locals[$name]) : isset($external[$name])))) { + return 'is stored on a property'; + } + + $target = $target->var; + } + + if (! $target instanceof Variable || ! is_string($target->name)) { + return $target instanceof StaticPropertyFetch ? 'is stored on a property' : null; + } + + return match (true) { + $target->name === 'GLOBALS', isset($globals[$target->name]) => 'is stored in a global variable', + isset($byReference[$target->name]) => 'is stored in a by-reference variable', + default => null, + }; + } + + /** + * Get the name of the function or method the call makes when it runs closures that can outlive the callback. + * + * @param array $locals + * @param array> $writes + * @param null|array $outer + */ + private function getSpawn(CallLike $call, array $locals, array $writes, ?array $outer, Scope $scope): ?string + { + if ($call->isFirstClassCallable()) { + return null; + } + + if ($call instanceof FuncCall) { + if (! $call->name instanceof Name || ! $this->reflectionProvider->hasFunction($call->name, $scope)) { + return null; + } + + $function = strtolower($this->reflectionProvider->getFunction($call->name, $scope)->getName()); + + return isset(self::SPAWNING_FUNCTIONS[$function]) && ($function !== 'hypervel\coroutine\wait' || ! $this->waitsForChild($call, $scope)) + ? $call->name->getLast() + : null; + } + + if (! ($call instanceof MethodCall || $call instanceof NullsafeMethodCall || $call instanceof StaticCall) || ! $call->name instanceof Identifier) { + return null; + } + + $name = $call->name->toString(); + $classNames = match (true) { + ! $call instanceof StaticCall => $this->getReceiverClassNames($call->var, $locals, $writes, $outer, $scope), + $call->class instanceof Name => [$scope->resolveName($call->class)], + default => [], + }; + + foreach ($classNames as $className) { + if (! $this->reflectionProvider->hasClass($className) || ! $this->reflectionProvider->getClass($className)->hasMethod($name)) { + continue; + } + + // An override runs its own body, so only the framework's declarations count. + $method = $this->reflectionProvider->getClass($className)->getMethod($name, $scope); + $declaringClass = $method->getDeclaringClass()->getName(); + $lowerName = strtolower($name); + + if (! in_array($lowerName, self::SPAWNING_METHODS[$declaringClass] ?? [], true) + || ($declaringClass === Waiter::class && $this->waitsForChild($call, $scope))) { + continue; + } + + return class_basename($declaringClass) . '::' . $method->getName(); + } + + return null; + } + + /** + * Determine whether a wait() call waits for its child to terminate on every exit, which keeps the child inside the callback. + * + * The option must be a constant expression: the lending call's scope does not know the callback's own variables. + */ + private function waitsForChild(CallLike $call, Scope $scope): bool + { + $argument = $call->getArg('waitForChildTermination', 3)?->value; + + return $argument !== null + && (new NodeFinder)->findFirstInstanceOf($argument, Variable::class) === null + && $scope->getType($argument)->isTrue()->yes(); + } + + /** + * Get the classes a method call's receiver may be, when the callback shows them. + * + * A local variable assigned only `new` expressions is one of those classes. An expression using no + * other variables than $this and those the callback receives unchanged has the type the lending + * call's scope gives it. + * + * @param array $locals the function's parameters and assigned variables + * @param array> $writes + * @param null|array $outer the variables the function receives unchanged from the lending call's scope, or null for all of them + * + * @return list + */ + private function getReceiverClassNames(Expr $receiver, array $locals, array $writes, ?array $outer, Scope $scope): array + { + if ($receiver instanceof Variable && is_string($receiver->name) && isset($writes[$receiver->name])) { + $classNames = []; + + foreach ($writes[$receiver->name] as $value) { + if (! $value instanceof New_ || ! $value->class instanceof Name) { + return []; + } + + $classNames[] = $scope->resolveName($value->class); + } + + return $classNames; + } + + foreach ((new NodeFinder)->findInstanceOf($receiver, Variable::class) as $variable) { + if ($variable->name !== 'this' + && (! is_string($variable->name) || isset($locals[$variable->name]) || ($outer !== null && ! isset($outer[$variable->name])))) { + return []; + } + } + + return $scope->getType($receiver)->getObjectClassNames(); + } + + /** + * Get the entry for a closure created now: the closure, what the first borrowed variable it captures by value + * holds, which it keeps for as long as it lives, and the by-reference variables it reads when it runs: its own + * and those of the closures it captures by value, even once their variables are replaced. + * + * @param Environment $environment + * + * @return HeldClosure + */ + private function getHeldClosure(Closure|ArrowFunction $closure, array $environment): array + { + $names = $this->getCapturedNames($closure); + $live = $closure instanceof Closure ? $this->getReferenceNames($closure) : []; + $names = array_diff_key($names, $live); + + foreach (array_intersect_key($environment['callables'], $names) as $closures) { + foreach ($closures as [, , $references]) { + $live += $references; + } + } + + return [$closure, $this->getFirstKind($names, $environment, live: false), $live]; + } + + /** + * Get the closures a nested function created now captures. + * + * The nested function runs later without this function's variables, so what a captured closure reads by + * reference from them is read now, leaving out the variables this function writes after the creation or in + * a loop around it, which may hold anything by then. A variable the nested function shares by reference is the + * same binding, which it can change itself, so only those variables are read from its own facts when it runs. + * + * @param Environment $environment + * @param Walk $walk + * + * @return array> + */ + private function getCarriedCallables(Closure|ArrowFunction $function, array $environment, int $position, array $walk): array + { + $shared = $function instanceof Closure ? $this->getReferenceNames($function) : []; + $unread = $shared; + + foreach (array_keys($environment['tracked'] + $environment['appended'] + $environment['callables']) as $name) { + if ($this->isRewritten($name, $position, $walk)) { + $unread[$name] = true; + } + } + + $now = [ + 'tracked' => array_diff_key($environment['tracked'], $unread), + 'appended' => array_diff_key($environment['appended'], $unread), + 'callables' => array_diff_key($environment['callables'], $unread), + ] + $environment; + + return array_map( + fn (array $closures): array => array_map( + fn (array $held): array => [$held[0], $held[1] ?? $this->getLiveKind($held, $now), array_intersect_key($held[2], $shared)], + $closures, + ), + $this->getCaptured($function, $environment['callables']), + ); + } + + /** + * Determine whether the function writes the variable after the position, or in a loop around the position, + * which runs that write again before it. + * + * @param Walk $walk + */ + private function isRewritten(string $name, int $position, array $walk): bool + { + foreach ($walk['positions'][$name] ?? [] as $written) { + if ($written >= $position) { + return true; + } + + foreach ($walk['loops'] as [$start, $end]) { + if ($start < $position && $position < $end && $start < $written) { + return true; + } + } + } + + return false; + } + + /** + * Get what the first borrowed variable a held closure reads by reference holds now, through itself or a + * closure it captured, or null when there is none. + * + * @param HeldClosure $held + * @param Environment $environment + * @param array $visited the variables already being read, which a closure capturing itself reaches again + */ + private function getLiveKind(array $held, array $environment, array $visited = []): ?string + { + $references = array_diff_key($held[2], $visited); + + return $references === [] ? null : $this->getFirstKind($references, $environment, $visited + $references); + } + + /** + * Get what the first of the variables that is borrowed holds now: its value, what was appended to it, or what + * a closure it may hold captured by value. A live read also includes what such a closure reads by reference now. + * + * @param array $names + * @param Environment $environment + * @param array $visited + */ + private function getFirstKind(array $names, array $environment, array $visited = [], bool $live = true): ?string + { + foreach ([$environment['tracked'], $environment['appended']] as $kinds) { + foreach (array_intersect_key($kinds, $names) as $kind) { + return $kind; + } + } + + foreach (array_intersect_key($environment['callables'], $names) as $closures) { + foreach ($closures as $held) { + $kind = $held[1] ?? ($live ? $this->getLiveKind($held, $environment, $visited) : null); + + if ($kind !== null) { + return $kind; + } + } + } + + return null; + } + + /** + * Get the names of the variables a nested function takes from its enclosing one: a closure's `use` + * variables, or the variables an arrow function's body reads other than its parameters, including + * those its own nested functions take. A nested class's body takes nothing. + * + * @return array + */ + private function getCapturedNames(Closure|ArrowFunction $function): array + { + if ($function instanceof Closure) { + return $this->getUseNames($function); + } + + return array_diff_key($this->getReadNames($function->expr), $this->getParameterNames($function)); + } + + /** + * Get the names of the variables an expression reads, including those its nested functions take. A nested + * class's body reads nothing. + * + * @return array + */ + private function getReadNames(Expr $expr): array + { + $visitor = new class($this->getCapturedNames(...)) extends NodeVisitorAbstract { + /** @var array */ + public array $names = []; + + /** + * @param \Closure(ArrowFunction|Closure): array $capturedNames + */ + public function __construct( + private \Closure $capturedNames, + ) { + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + + if ($node instanceof Closure || $node instanceof ArrowFunction) { + $this->names += ($this->capturedNames)($node); + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Variable && is_string($node->name)) { + $this->names[$node->name] = true; + } + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse([$expr]); + + return $visitor->names; + } + + /** + * Get the entries for the variables a nested function receives from its enclosing one: a closure's + * `use` variables and $this, or an arrow function's variables other than its parameters. The DB + * facade is reachable everywhere. + * + * @template TValue + * + * @param array $variables + * + * @return array + */ + private function getCaptured(Closure|ArrowFunction $function, array $variables): array + { + $received = $function instanceof Closure ? $this->getUseNames($function) : null; + $parameters = $this->getParameterNames($function); + $captured = []; + + foreach ($variables as $name => $value) { + $receives = match (true) { + $name === DB::class => true, + $name === 'this', str_starts_with($name, 'this->') => ! $function->static, + $received !== null => isset($received[$name]), + default => ! isset($parameters[$name]), + }; + + if ($receives) { + $captured[$name] = $value; + } + } + + return $captured; + } + + /** + * Get a closure's `use` variable names. + * + * @return array + */ + private function getUseNames(Closure $closure): array + { + $names = []; + + foreach ($closure->uses as $use) { + if (is_string($use->var->name)) { + $names[$use->var->name] = true; + } + } + + return $names; + } + + /** + * Get a closure's by-reference `use` variable names. + * + * @return array + */ + private function getReferenceNames(Closure $closure): array + { + $names = []; + + foreach ($closure->uses as $use) { + if ($use->byRef && is_string($use->var->name)) { + $names[$use->var->name] = true; + } + } + + return $names; + } + + /** + * Get a function's parameter names. + * + * @return array + */ + private function getParameterNames(Closure|ArrowFunction $function): array + { + $names = []; + + foreach ($function->params as $parameter) { + if ($parameter->var instanceof Variable && is_string($parameter->var->name)) { + $names[$parameter->var->name] = true; + } + } + + return $names; + } + + /** + * Add the error for a borrowed value escaping, once per line and message. + * + * @param array $errors + * @param array{method: string, resource: string} $borrow + */ + private function addError(array &$errors, Node $node, array $borrow, string $kind, string $how): void + { + $lent = self::RESOURCES[$borrow['resource']][0]; + + $subject = match (true) { + $lent === null => sprintf('The %s taken inside %s()', $kind, $borrow['method']), + $kind === 'lent' => sprintf('The %s borrowed by %s()', $lent, $borrow['method']), + default => sprintf('The %s of the %s borrowed by %s()', $kind, $lent, $borrow['method']), + }; + + $end = $lent === null ? 'pin' : 'borrow'; + $message = sprintf('%s %s, so it can be used after the %s ends.', $subject, $how, $end); + $tip = sprintf('Finish using it inside the callback, which holds the %s.', $end); + + if (str_starts_with($how, 'is captured')) { + $tip .= ' A child coroutine that must use it can run through wait() with waitForChildTermination: true.'; + } elseif ($how === 'is used in a generator callback') { + $tip = sprintf('A generator callback only runs once it is iterated, after the %s ends, so return the results instead of yielding them.', $end); + } + + $errors[$node->getStartLine() . $message] ??= RuleErrorBuilder::message($message) + ->line($node->getStartLine()) + ->identifier('hypervel.borrowedResourceEscape') + ->addTip($tip) + ->build(); + } +} diff --git a/src/phpstan/src/Rules/Coroutine/CoroutineRuleHelper.php b/src/phpstan/src/Rules/Coroutine/CoroutineRuleHelper.php index 62b06074f..b8b9b57e3 100644 --- a/src/phpstan/src/Rules/Coroutine/CoroutineRuleHelper.php +++ b/src/phpstan/src/Rules/Coroutine/CoroutineRuleHelper.php @@ -7,30 +7,80 @@ use Closure; use Hypervel\Coroutine\Coroutine; use PhpParser\Node; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrayDimFetch; +use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\AssignOp; +use PhpParser\Node\Expr\AssignOp\Coalesce; +use PhpParser\Node\Expr\AssignRef; use PhpParser\Node\Expr\BooleanNot; +use PhpParser\Node\Expr\CallLike; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\List_; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Expr\Throw_; use PhpParser\Node\Expr\Variable; use PhpParser\Node\FunctionLike; use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Node\StaticVar; use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Catch_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\Expression; +use PhpParser\Node\Stmt\Foreach_; +use PhpParser\Node\Stmt\If_; +use PhpParser\Node\Stmt\Static_; +use PhpParser\Node\Stmt\TryCatch; +use PhpParser\Node\Stmt\Unset_; +use PhpParser\Node\VariadicPlaceholder; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor; use PhpParser\NodeVisitorAbstract; +use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\TrinaryLogic; +use PHPStan\Type\MixedType; +use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use Swoole\Coroutine\CanceledException; +use function array_any; +use function array_map; +use function count; use function end; +use function in_array; +use function is_string; use function strtolower; #[AutowiredService] class CoroutineRuleHelper { + /** + * The native channel, WaitGroup, Waiter and join methods, keyed by declaring class, that a try may call and still + * keep an exact catch: each either doesn't wait or throws cancellation directly. + */ + private const array NATIVE_WAITS = [ + 'Swoole\Coroutine\Channel' => ['push', 'pop', 'close'], + 'Hypervel\Engine\Channel' => ['push', 'pop', 'close'], + 'Hypervel\Contracts\Engine\ChannelInterface' => ['push', 'pop', 'close'], + 'Swoole\Coroutine\WaitGroup' => ['add', 'done', 'wait'], + 'Hypervel\Coroutine\WaitGroup' => ['add', 'done', 'wait'], + 'Hypervel\Coroutine\Waiter' => ['wait'], + 'Swoole\Coroutine' => ['join'], + 'Hypervel\Coroutine\Coroutine' => ['join'], + 'Hypervel\Engine\Coroutine' => ['join'], + ]; + /** * Create a new coroutine rule helper instance. */ @@ -98,6 +148,134 @@ public function endsWith(array $statements, array $exits): bool return false; } + /** + * Determine whether the node writes the variable: =, =& or a compound assignment, including through + * destructuring, a foreach key or value, or a catch binding. + */ + public function assigns(Node $node, string $variable): bool + { + return array_any(self::getWriteTargets($node), static fn (Expr $target): bool => $target instanceof Variable && $target->name === $variable); + } + + /** + * Get the targets the node writes, such as variables and properties: through =, =& or a compound + * assignment, including destructuring, a foreach key or value, or a catch binding. + * + * @return list + */ + public static function getWriteTargets(Node $node): array + { + return match (true) { + $node instanceof Assign, $node instanceof AssignRef, $node instanceof AssignOp => self::getTargetLeaves($node->var), + $node instanceof Foreach_ => [...self::getTargetLeaves($node->keyVar), ...self::getTargetLeaves($node->valueVar)], + $node instanceof Catch_ => self::getTargetLeaves($node->var), + default => [], + }; + } + + /** + * Get the targets a write assigns, such as variables and properties, through destructuring. + * + * @return list + */ + public static function getTargetLeaves(?Expr $target): array + { + if ($target === null) { + return []; + } + + if (! $target instanceof List_ && ! $target instanceof Array_) { + return [$target]; + } + + $leaves = []; + + foreach ($target->items as $item) { + $leaves = [...$leaves, ...self::getTargetLeaves($item?->value)]; + } + + return $leaves; + } + + /** + * Get the destructuring leaves the target binds by reference, such as `$value` in `[&$value] = $items`. + * + * @return list + */ + public static function getReferenceLeaves(?Expr $target): array + { + if (! $target instanceof List_ && ! $target instanceof Array_) { + return []; + } + + $leaves = []; + + foreach ($target->items as $item) { + if ($item !== null) { + $leaves = [...$leaves, ...($item->byRef ? [$item->value] : self::getReferenceLeaves($item->value))]; + } + } + + return $leaves; + } + + /** + * Get the plain variable a reference assignment binds to one of the given variables or an offset of it, and + * that root: `$alias =& $root['key']`, or `$root['key'] =& $alias`. A plain variable assigned by reference is + * only re-pointed, so it never passes its old binding on. A property isn't followed, since a property can + * return other storage by reference than its object's. + * + * @param array $roots + * + * @return null|array{string, string} + */ + public static function getStorageAlias(AssignRef $node, array $roots): ?array + { + [$alias, $storage] = $node->var instanceof Variable ? [$node->var, $node->expr] : [$node->expr, $node->var]; + + while ($storage instanceof ArrayDimFetch) { + $storage = $storage->var; + } + + return $alias instanceof Variable + && is_string($alias->name) + && $storage instanceof Variable + && is_string($storage->name) + && isset($roots[$storage->name]) + ? [$alias->name, $storage->name] + : null; + } + + /** + * Get the variable names the node unbinds from what they were bound to, such as a global or a caller's + * by-reference variable: unset(), a static declaration, or binding the variable itself by reference through + * =&, a by-reference foreach value or a by-reference destructuring item. Unsetting or binding an offset or + * property by reference writes through the binding instead. + * + * @return list + */ + public static function getDetachedNames(Node $node): array + { + $variables = match (true) { + $node instanceof Unset_ => $node->vars, + $node instanceof Static_ => array_map(static fn (StaticVar $variable): Variable => $variable->var, $node->vars), + $node instanceof AssignRef => [$node->var], + $node instanceof Assign => self::getReferenceLeaves($node->var), + $node instanceof Foreach_ => $node->byRef ? [$node->valueVar] : self::getReferenceLeaves($node->valueVar), + default => [], + }; + + $names = []; + + foreach ($variables as $variable) { + if ($variable instanceof Variable && is_string($variable->name)) { + $names[] = $variable->name; + } + } + + return $names; + } + /** * Find the matching nodes in the statements, skipping the bodies of nested functions and classes, * and of nested catches that bind the given variable again, since it names another exception there. @@ -142,4 +320,443 @@ public function enterNode(Node $node): ?int return $visitor->found; } + + /** + * Get the type a call's receiver, or a static call's class expression, has in the try block, or null when it is unknown. + * + * The receiver is typed in the scope at the try's start, so it must not take a value the block may change + * from there. A chain rooted at a local variable that the block only calls methods on keeps the variable's + * type. One assignment, as a top-level statement before every call on the variable, gives it the assigned + * value, so the chain is typed with that expression in the variable's place; a `??=` one keeps a value + * that is always set and never null, without reading the expression it never assigns, and otherwise gives + * it the non-null value it held or the assigned one. Any other write leaves it unknown. A call argument + * that reads a local the block may change is typed as unknown, so a call that selects its result's class + * by it, such as the container's make(), gives no class, while a fluent where() keeps its own. Any other + * read of such a local leaves the receiver unknown. + * + * A `$this` property the block writes directly follows the same rules, and calls that change a property + * aren't followed. A call on null fails or doesn't run, so null is left out of the type. + * + * @param array $statements + */ + public function getReceiverType(Expr $receiver, array $statements, Scope $scope): ?Type + { + $root = $receiver; + + while ($root instanceof MethodCall || $root instanceof NullsafeMethodCall) { + $root = $root->var; + } + + $name = $root instanceof Variable && is_string($root->name) && $root->name !== 'this' ? $root->name : null; + $property = $name === null ? self::getThisPropertyName($root) : null; + + $matches = static fn (?Node $node): bool => $name !== null + ? $node instanceof Variable && $node->name === $name + : $property !== null && self::getThisPropertyName($node) === $property; + + if ($name === null) { + $written = $property !== null && $this->find( + $statements, + static fn (Node $node): bool => array_any(self::getWriteTargets($node), $matches), + ) !== []; + + if (! $written) { + $typed = $this->getEntryExpression($receiver, $statements); + + return $typed === null ? null : TypeCombinator::removeNull($scope->getType($typed)); + } + + // A hook can return or store another object than the one assigned. + $class = $scope->getClassReflection(); + $reflection = $class !== null && $class->hasNativeProperty($property) ? $class->getNativeProperty($property) : null; + + if ($reflection === null + || $reflection->isStatic() + || ! $reflection->isVirtual()->no() + || $reflection->hasHook('get') + || $reflection->hasHook('set')) { + return null; + } + } + + $nodes = $this->find( + $statements, + static fn (Node $node): bool => $matches($node) + || $matches(self::getCalledReceiver($node)) + || (($node instanceof Assign || $node instanceof Coalesce) && $matches($node->var)), + ); + + $mentions = []; + $calls = []; + $assignments = []; + + foreach ($nodes as $node) { + if ($matches($node)) { + $mentions[] = $node; + } elseif ($node instanceof Assign || $node instanceof Coalesce) { + $assignments[] = $node; + } else { + $calls[] = $node; + } + } + + if (count($mentions) !== count($calls) + count($assignments)) { + return null; + } + + $assignment = $assignments[0] ?? null; + + if ($assignment !== null) { + if (count($assignments) > 1 + || ! array_any($statements, static fn (Node $statement): bool => $statement instanceof Expression && $statement->expr === $assignment)) { + return null; + } + + foreach ($calls as $call) { + if ($call->getStartFilePos() < $assignment->getEndFilePos()) { + return null; + } + } + } + + $value = $assignment?->expr; + + if ($assignment instanceof Coalesce) { + // A typed property may be uninitialized. + $defined = $name !== null ? $scope->hasVariableType($name) : TrinaryLogic::createMaybe(); + + // The assignment never runs when the variable always holds a value, so its expression is never read. + if ($defined->yes() && $scope->getType($root)->isNull()->no()) { + $value = null; + } else { + $assigned = $this->getEntryExpression($assignment->expr, $statements); + + if ($assigned === null) { + return null; + } + + $value = new TypeExpr($defined->no() + ? $scope->getType($assigned) + : TypeCombinator::union(TypeCombinator::removeNull($scope->getType($root)), $scope->getType($assigned))); + } + } + + $typed = $this->getEntryExpression($receiver, $statements, $root, $value); + + return $typed === null ? null : TypeCombinator::removeNull($scope->getType($typed)); + } + + /** + * Find the try's exact CanceledException catches that rethrow or keep the cancellation, each with the + * later catch of every exception that handles cancellation carried by another exception as an ordinary + * failure. + * + * @return list + */ + public function findExactCancellationMisses(TryCatch $node, Scope $scope): array + { + $misses = []; + + foreach ($node->catches as $index => $catch) { + if (! $this->passesCancellationOn($catch)) { + continue; + } + + $broadCatch = $this->findBroadCatch($node, $index); + + if ($broadCatch === null + || $this->keepsCancellationApart($broadCatch) + || $this->onlyWaitsNatively($node->stmts, $scope)) { + continue; + } + + $misses[] = [$catch, $broadCatch]; + } + + return $misses; + } + + /** + * Determine whether the catch takes CanceledException and rethrows it, or keeps it in another variable for later. + */ + private function passesCancellationOn(Catch_ $catch): bool + { + if ($catch->var === null || ! is_string($catch->var->name)) { + return false; + } + + $catchesCancellation = false; + + foreach ($catch->types as $type) { + $catchesCancellation = $catchesCancellation || $this->isCancellation($type); + } + + $variable = $catch->var->name; + + return $catchesCancellation && $this->find( + $catch->stmts, + static fn (Node $node): bool => ($node instanceof Throw_ || $node instanceof Assign || $node instanceof Coalesce) + && $node->expr instanceof Variable + && $node->expr->name === $variable, + $variable, + ) !== []; + } + + /** + * Find the first catch after the given one that takes every exception. + */ + private function findBroadCatch(TryCatch $node, int $index): ?Catch_ + { + foreach ($node->catches as $laterIndex => $catch) { + if ($laterIndex <= $index) { + continue; + } + + foreach ($catch->types as $type) { + if (in_array($type->toLowerString(), ['throwable', 'exception'], true)) { + return $catch; + } + } + } + + return null; + } + + /** + * Determine whether the catch only rethrows its exception, or handles cancellation behind a + * Coroutine::causedByCancellation() guard that throws, that is the catch's only statement, or + * that is negated and followed only by rethrowing the exception. + */ + private function keepsCancellationApart(Catch_ $catch): bool + { + if ($catch->var === null || ! is_string($catch->var->name)) { + return false; + } + + $variable = $catch->var->name; + $first = $catch->stmts[0] ?? null; + + if ($first instanceof Expression) { + return count($catch->stmts) === 1 && $this->rethrows($first, $variable); + } + + if (! $first instanceof If_) { + return false; + } + + $polarity = $this->guardPolarity($first->cond, $variable); + + return $polarity !== null + && (count($catch->stmts) === 1 + || ($polarity && $this->endsWith($first->stmts, [Throw_::class])) + || (! $polarity + && $first->elseifs === [] + && $first->else === null + && count($catch->stmts) === 2 + && $this->rethrows($catch->stmts[1], $variable))); + } + + /** + * Determine whether the statement throws the variable unchanged. + */ + private function rethrows(Stmt $statement, string $variable): bool + { + return $statement instanceof Expression + && $statement->expr instanceof Throw_ + && $statement->expr->expr instanceof Variable + && $statement->expr->expr->name === $variable; + } + + /** + * Determine whether every call the try block makes is a native channel, WaitGroup, Waiter or join wait. + * + * @param array $statements + */ + private function onlyWaitsNatively(array $statements, Scope $scope): bool + { + $calls = $this->find( + $statements, + static fn (Node $node): bool => $node instanceof CallLike && ! $node->isFirstClassCallable(), + ); + + foreach ($calls as $call) { + $native = match (true) { + $call instanceof MethodCall, $call instanceof NullsafeMethodCall => $call->name instanceof Identifier + && ($receiverType = $this->getReceiverType($call->var, $statements, $scope)) !== null + && $this->isNativeWait($receiverType->getObjectClassReflections(), $call->name->toString()), + $call instanceof StaticCall => $call->class instanceof Name + && $call->name instanceof Identifier + && $this->isNativeWait($scope->resolveTypeByName($call->class)->getObjectClassReflections(), $call->name->toString()), + $call instanceof New_ => $call->class instanceof Name + && isset(self::NATIVE_WAITS[$scope->resolveName($call->class)]), + default => false, + }; + + if (! $native) { + return false; + } + } + + return true; + } + + /** + * Rebuild a call chain for typing in the scope at the try, with each call argument that reads a local the + * block may change replaced by an unknown value. Return null when another part of it reads such a local. + * The chain's root variable or property has been checked by the caller: it keeps its value from the try, or + * takes the value the block assigns it. New nodes are built rather than cloned, since a clone keeps the + * original's cached expression key. + * + * @param array $statements + */ + private function getEntryExpression(Expr $expression, array $statements, ?Expr $root = null, ?Expr $value = null): ?Expr + { + if ($expression === $root) { + return $value === null ? $root : $this->getEntryExpression($value, $statements); + } + + if ($expression instanceof MethodCall || $expression instanceof NullsafeMethodCall) { + $var = $this->getEntryExpression($expression->var, $statements, $root, $value); + + if ($var === null || ($expression->name instanceof Expr && $this->mayChangeInputs([$expression->name], $statements))) { + return null; + } + + $args = $this->generalizeArguments($expression->args, $statements); + + return $expression instanceof MethodCall + ? new MethodCall($var, $expression->name, $args) + : new NullsafeMethodCall($var, $expression->name, $args); + } + + return match (true) { + $expression instanceof StaticCall && $expression->class instanceof Name && $expression->name instanceof Identifier => new StaticCall($expression->class, $expression->name, $this->generalizeArguments($expression->args, $statements)), + $expression instanceof New_ && $expression->class instanceof Name => new New_($expression->class, $this->generalizeArguments($expression->args, $statements)), + $expression instanceof FuncCall && $expression->name instanceof Name => new FuncCall($expression->name, $this->generalizeArguments($expression->args, $statements)), + $this->mayChangeInputs([$expression], $statements) => null, + default => $expression, + }; + } + + /** + * Replace each argument that reads a local the block may change with an unknown value. + * + * @param array $args + * @param array $statements + * @return array + */ + private function generalizeArguments(array $args, array $statements): array + { + foreach ($args as $index => $arg) { + if ($arg instanceof Arg && $this->mayChangeInputs([$arg], $statements)) { + $args[$index] = new Arg(new TypeExpr(new MixedType), $arg->byRef, $arg->unpack, [], $arg->name); + } + } + + return $args; + } + + /** + * Determine whether the block may change a local variable the nodes read: it mentions the variable + * outside them, other than to call a method on it. A `$this` property they read changes when the block + * writes it directly. + * + * @param array $nodes + * @param array $statements + */ + private function mayChangeInputs(array $nodes, array $statements): bool + { + $properties = []; + + foreach ($this->find($nodes, static fn (Node $node): bool => $node instanceof PropertyFetch) as $fetch) { + $property = self::getThisPropertyName($fetch); + + if ($property !== null) { + $properties[$property] = true; + } + } + + $writesProperty = static fn (Expr $target): bool => ($property = self::getThisPropertyName($target)) !== null && isset($properties[$property]); + + if ($properties !== [] && $this->find($statements, static fn (Node $node): bool => array_any(self::getWriteTargets($node), $writesProperty)) !== []) { + return true; + } + + $inputs = $this->find($nodes, static fn (Node $node): bool => $node instanceof Variable && is_string($node->name) && $node->name !== 'this'); + + foreach ($inputs as $input) { + $name = $input instanceof Variable ? $input->name : null; + $receivers = []; + $mentions = []; + $matches = static fn (?Node $node): bool => $node instanceof Variable && $node->name === $name; + + $nodes = $this->find( + $statements, + static fn (Node $node): bool => $matches($node) || $matches(self::getCalledReceiver($node)), + ); + + foreach ($nodes as $node) { + if ($matches($node)) { + $mentions[] = $node; + } else { + $receivers[] = self::getCalledReceiver($node); + } + } + + foreach ($mentions as $mention) { + if (! in_array($mention, $inputs, true) && ! in_array($mention, $receivers, true)) { + return true; + } + } + } + + return false; + } + + /** + * Get the expression a method or static call is made on. + */ + private static function getCalledReceiver(Node $node): ?Node + { + return match (true) { + $node instanceof MethodCall, $node instanceof NullsafeMethodCall => $node->var, + $node instanceof StaticCall => $node->class, + default => null, + }; + } + + /** + * Get the name of the `$this` property the node reads or writes, or null for any other node. + */ + private static function getThisPropertyName(?Node $node): ?string + { + return $node instanceof PropertyFetch + && $node->var instanceof Variable + && $node->var->name === 'this' + && $node->name instanceof Identifier + ? $node->name->toString() + : null; + } + + /** + * Determine whether the method, on every class it may be called on, is declared as a native wait. + * + * @param list $classReflections + */ + private function isNativeWait(array $classReflections, string $method): bool + { + foreach ($classReflections as $classReflection) { + if (! $classReflection->hasNativeMethod($method)) { + return false; + } + + $declaringClass = $classReflection->getNativeMethod($method)->getDeclaringClass()->getName(); + + if (! in_array(strtolower($method), self::NATIVE_WAITS[$declaringClass] ?? [], true)) { + return false; + } + } + + return $classReflections !== []; + } } diff --git a/src/phpstan/src/Rules/Coroutine/ExactCancellationCatchRule.php b/src/phpstan/src/Rules/Coroutine/ExactCancellationCatchRule.php index 4d212f6c0..a23db42ea 100644 --- a/src/phpstan/src/Rules/Coroutine/ExactCancellationCatchRule.php +++ b/src/phpstan/src/Rules/Coroutine/ExactCancellationCatchRule.php @@ -5,38 +5,15 @@ namespace Hypervel\PHPStan\Rules\Coroutine; use PhpParser\Node; -use PhpParser\Node\Expr; -use PhpParser\Node\Expr\Assign; -use PhpParser\Node\Expr\AssignOp\Coalesce; -use PhpParser\Node\Expr\CallLike; -use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\New_; -use PhpParser\Node\Expr\NullsafeMethodCall; -use PhpParser\Node\Expr\StaticCall; -use PhpParser\Node\Expr\Throw_; -use PhpParser\Node\Expr\Variable; -use PhpParser\Node\Identifier; -use PhpParser\Node\Name; -use PhpParser\Node\Stmt\Catch_; -use PhpParser\Node\Stmt\Expression; -use PhpParser\Node\Stmt\If_; use PhpParser\Node\Stmt\TryCatch; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Conditional; -use PHPStan\Reflection\ClassReflection; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\ObjectType; -use PHPStan\Type\Type; -use function array_any; -use function count; -use function in_array; -use function is_string; use function sprintf; -use function strtolower; /** * Reports an exact CanceledException catch backed by a later catch of every exception. @@ -49,21 +26,6 @@ #[Conditional(attribute: new AutowiredService, if: '%checkCancellationHandling%')] class ExactCancellationCatchRule implements Rule { - /** - * The native wait methods, keyed by declaring class, that throw cancellation directly and so may keep an exact catch. - */ - private const array NATIVE_WAITS = [ - 'Swoole\Coroutine\Channel' => ['push', 'pop', 'close'], - 'Hypervel\Engine\Channel' => ['push', 'pop', 'close'], - 'Hypervel\Contracts\Engine\ChannelInterface' => ['push', 'pop', 'close'], - 'Swoole\Coroutine\WaitGroup' => ['add', 'done', 'wait'], - 'Hypervel\Coroutine\WaitGroup' => ['add', 'done', 'wait'], - 'Hypervel\Coroutine\Waiter' => ['wait'], - 'Swoole\Coroutine' => ['join'], - 'Hypervel\Coroutine\Coroutine' => ['join'], - 'Hypervel\Engine\Coroutine' => ['join'], - ]; - /** * Create a new exact cancellation catch rule instance. */ @@ -91,19 +53,7 @@ public function processNode(Node $node, Scope $scope): array { $errors = []; - foreach ($node->catches as $index => $catch) { - if (! $this->passesCancellationOn($catch)) { - continue; - } - - $broadCatch = $this->findBroadCatch($node, $index); - - if ($broadCatch === null - || $this->keepsCancellationApart($broadCatch) - || $this->onlyWaitsNatively($node->stmts, $scope)) { - continue; - } - + foreach ($this->coroutineRuleHelper->findExactCancellationMisses($node, $scope) as [$catch, $broadCatch]) { $errors[] = RuleErrorBuilder::message(sprintf( 'Catching CanceledException misses cancellation carried by another exception, which the catch on line %d handles as an ordinary failure. Check Coroutine::causedByCancellation() in that catch instead.', $broadCatch->getStartLine(), @@ -116,200 +66,4 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - - /** - * Determine whether the catch takes CanceledException and rethrows it, or keeps it in another variable for later. - */ - private function passesCancellationOn(Catch_ $catch): bool - { - if ($catch->var === null || ! is_string($catch->var->name)) { - return false; - } - - $catchesCancellation = false; - - foreach ($catch->types as $type) { - $catchesCancellation = $catchesCancellation || $this->coroutineRuleHelper->isCancellation($type); - } - - $variable = $catch->var->name; - - return $catchesCancellation && $this->coroutineRuleHelper->find( - $catch->stmts, - static fn (Node $node): bool => ($node instanceof Throw_ || $node instanceof Assign || $node instanceof Coalesce) - && $node->expr instanceof Variable - && $node->expr->name === $variable, - $variable, - ) !== []; - } - - /** - * Find the first catch after the given one that takes every exception. - */ - private function findBroadCatch(TryCatch $node, int $index): ?Catch_ - { - foreach ($node->catches as $laterIndex => $catch) { - if ($laterIndex <= $index) { - continue; - } - - foreach ($catch->types as $type) { - if (in_array($type->toLowerString(), ['throwable', 'exception'], true)) { - return $catch; - } - } - } - - return null; - } - - /** - * Determine whether the catch only rethrows its exception, or handles cancellation behind a - * Coroutine::causedByCancellation() guard that throws or that is the catch's only statement. - */ - private function keepsCancellationApart(Catch_ $catch): bool - { - if ($catch->var === null || ! is_string($catch->var->name)) { - return false; - } - - $variable = $catch->var->name; - $first = $catch->stmts[0] ?? null; - - if ($first instanceof Expression) { - return count($catch->stmts) === 1 - && $first->expr instanceof Throw_ - && $first->expr->expr instanceof Variable - && $first->expr->expr->name === $variable; - } - - if (! $first instanceof If_) { - return false; - } - - $polarity = $this->coroutineRuleHelper->guardPolarity($first->cond, $variable); - - return $polarity !== null - && (count($catch->stmts) === 1 - || ($polarity && $this->coroutineRuleHelper->endsWith($first->stmts, [Throw_::class]))); - } - - /** - * Determine whether every call the try block makes is a native channel, WaitGroup, Waiter or join wait. - * - * @param array $statements - */ - private function onlyWaitsNatively(array $statements, Scope $scope): bool - { - $calls = $this->coroutineRuleHelper->find( - $statements, - static fn (Node $node): bool => $node instanceof CallLike && ! $node->isFirstClassCallable(), - ); - - foreach ($calls as $call) { - $native = match (true) { - $call instanceof MethodCall, $call instanceof NullsafeMethodCall => $call->name instanceof Identifier - && ($receiverType = $this->getReceiverType($call->var, $statements, $scope)) !== null - && $this->isNativeWait($receiverType->getObjectClassReflections(), $call->name->toString()), - $call instanceof StaticCall => $call->class instanceof Name - && $call->name instanceof Identifier - && $this->isNativeWait($scope->resolveTypeByName($call->class)->getObjectClassReflections(), $call->name->toString()), - $call instanceof New_ => $call->class instanceof Name - && isset(self::NATIVE_WAITS[$scope->resolveName($call->class)]), - default => false, - }; - - if (! $native) { - return false; - } - } - - return true; - } - - /** - * Get the type a receiver has in the try block, or null when it is unknown. - * - * The type at the try's start holds for a local variable that the block only calls methods on. One - * construction, as a top-level statement before every call, gives it the constructed class; any other - * write leaves it unknown. - * - * @param array $statements - */ - private function getReceiverType(Expr $receiver, array $statements, Scope $scope): ?Type - { - if (! $receiver instanceof Variable || ! is_string($name = $receiver->name)) { - return $scope->getType($receiver); - } - - $nodes = $this->coroutineRuleHelper->find( - $statements, - static fn (Node $node): bool => ($node instanceof Variable && $node->name === $name) - || (($node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof Assign) - && $node->var instanceof Variable - && $node->var->name === $name), - ); - - $mentions = []; - $calls = []; - $assignments = []; - - foreach ($nodes as $node) { - if ($node instanceof Variable) { - $mentions[] = $node; - } elseif ($node instanceof Assign) { - $assignments[] = $node; - } else { - $calls[] = $node; - } - } - - if (count($mentions) !== count($calls) + count($assignments)) { - return null; - } - - if ($assignments === []) { - return $scope->getType($receiver); - } - - $assignment = $assignments[0]; - $construction = $assignment->expr; - - if (count($assignments) > 1 - || ! $construction instanceof New_ - || ! $construction->class instanceof Name - || ! array_any($statements, static fn (Node $statement): bool => $statement instanceof Expression && $statement->expr === $assignment)) { - return null; - } - - foreach ($calls as $call) { - if ($call->getStartFilePos() < $assignment->getEndFilePos()) { - return null; - } - } - - return new ObjectType($scope->resolveName($construction->class)); - } - - /** - * Determine whether the method, on every class it may be called on, is declared as a native wait. - * - * @param list $classReflections - */ - private function isNativeWait(array $classReflections, string $method): bool - { - foreach ($classReflections as $classReflection) { - if (! $classReflection->hasNativeMethod($method)) { - return false; - } - - $declaringClass = $classReflection->getNativeMethod($method)->getDeclaringClass()->getName(); - - if (! in_array(strtolower($method), self::NATIVE_WAITS[$declaringClass] ?? [], true)) { - return false; - } - } - - return $classReflections !== []; - } } diff --git a/src/phpstan/src/Rules/Coroutine/NoCoroutinesInTransactionsRule.php b/src/phpstan/src/Rules/Coroutine/NoCoroutinesInTransactionsRule.php index 0b692806d..f95c4d757 100644 --- a/src/phpstan/src/Rules/Coroutine/NoCoroutinesInTransactionsRule.php +++ b/src/phpstan/src/Rules/Coroutine/NoCoroutinesInTransactionsRule.php @@ -128,12 +128,15 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { + // PHPStan also analyzes a nullsafe call as a method call. + if ($node instanceof NullsafeMethodCall) { + return []; + } + // Every call reaches this rule, so cheap name and call-stack checks come before reflection. $name = $this->getCalledName($node); - if ((! isset(self::SPAWNING_NAMES[$name]) && $name !== 'aftercommit') - || $node->isFirstClassCallable() - || ! $this->runsInTransaction($scope)) { + if ((! isset(self::SPAWNING_NAMES[$name]) && $name !== 'aftercommit') || ! $this->runsInTransaction($scope)) { return []; } diff --git a/src/phpstan/src/Rules/Coroutine/NoRawCoroutinesRule.php b/src/phpstan/src/Rules/Coroutine/NoRawCoroutinesRule.php new file mode 100644 index 000000000..73e6154ef --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/NoRawCoroutinesRule.php @@ -0,0 +1,118 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%noRawCoroutines%')] +class NoRawCoroutinesRule implements Rule +{ + /** + * The function that starts a raw coroutine. Swoole's global go() needs short names, which the framework rejects. + */ + private const string FUNCTION = 'swoole\coroutine\go'; + + /** + * The declaring classes of the methods that start a raw coroutine, by lowercase method name. + */ + private const array METHODS = [ + 'create' => [SwooleCoroutine::class, EngineCoroutine::class], + 'execute' => [EngineCoroutine::class], + ]; + + /** + * Create a new raw coroutines rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private FacadeHelper $facadeHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report a call that starts a raw coroutine. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $spawn = null; + + if ($node instanceof FuncCall) { + $function = $node->name instanceof Name ? $this->reflectionProvider->resolveFunctionName($node->name, $scope) : null; + + if ($function !== null && strtolower($function) === self::FUNCTION) { + $spawn = $function; + } + } elseif (($node instanceof MethodCall || $node instanceof StaticCall) + && $node->name instanceof Identifier + && isset(self::METHODS[strtolower($node->name->toString())]) + ) { + // PHPStan also analyzes a nullsafe call as a method call. + foreach ($this->facadeHelper->getCalledMethods($node, $node->name->toString(), $scope) as $method) { + $class = $method->getDeclaringClass()->getName(); + + if (in_array($class, self::METHODS[strtolower($method->getName())], true)) { + $spawn = $class . '::' . $method->getName(); + + break; + } + } + } + + if ($spawn === null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('%s() starts a coroutine without the framework\'s exception handling, so an uncaught exception or cancellation stops the worker.', $spawn)) + ->identifier('hypervel.rawCoroutine') + ->addTip('Use Hypervel\Coroutine\Coroutine::create(), go() or co() instead. Coroutine::fork() and the helpers\' copyContext argument copy the parent\'s context. Keep a direct call, with an ignore, only when its callback catches every exception itself.') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/RestrictedAttributeRule.php b/src/phpstan/src/Rules/Coroutine/RestrictedAttributeRule.php new file mode 100644 index 000000000..cfd5c9a2b --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/RestrictedAttributeRule.php @@ -0,0 +1,117 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkRestrictedCalls%')] +class RestrictedAttributeRule implements Rule +{ + /** + * Create a new restricted attribute rule instance. + */ + public function __construct( + private WorkerStateHelper $helper, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + /** + * Report the method's malformed lifetime marker. + * + * @param InClassMethodNode $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if ($node->getOriginalNode()->attrGroups === []) { + return []; + } + + $method = $node->getMethodReflection(); + $attribute = $this->helper->getLifetimeAttribute($method); + + if ($attribute === null) { + return []; + } + + $marker = sprintf('#[%s] on %s::%s()', class_basename($attribute->getName()), $method->getDeclaringClass()->getName(), $method->getName()); + $arguments = $attribute->getArgumentTypes(); + $errors = []; + $names = []; + + foreach (['whenNotDefault', 'whenProvided'] as $argument) { + $values = ($arguments[$argument] ?? null)?->getConstantStrings() ?? []; + + if (count($values) === 1) { + $names[$argument] = $values[0]->getValue(); + } + } + + if (count($names) === 2) { + $errors[] = RuleErrorBuilder::message(sprintf('%s sets both whenNotDefault and whenProvided, but a marker takes one parameter condition.', $marker)) + ->tip('Keep the condition that decides whether a call is restricted.') + ->identifier('hypervel.restrictedAttribute') + ->build(); + } + + foreach ($names as $argument => $name) { + $parameter = array_find( + $method->getOnlyVariant()->getParameters(), + static fn (ParameterReflection $parameter): bool => $parameter->getName() === $name, + ); + + if ($parameter === null) { + $errors[] = RuleErrorBuilder::message(sprintf('%s names $%s in %s, but the method has no such parameter.', $marker, $name, $argument)) + ->tip('Name the parameter whose argument makes a call restricted.') + ->identifier('hypervel.restrictedAttribute') + ->build(); + } elseif ($parameter->getDefaultValue() === null) { + $errors[] = RuleErrorBuilder::message(sprintf('%s names $%s in %s, but $%s has no default, so every call is restricted.', $marker, $name, $argument, $name)) + ->tip('Name an optional parameter, or remove the condition.') + ->identifier('hypervel.restrictedAttribute') + ->build(); + } + } + + if ($method->isStatic() && (($arguments['whenShared'] ?? null)?->isTrue()->yes() ?? false)) { + $errors[] = RuleErrorBuilder::message(sprintf('%s sets whenShared, but a static method has no instance for the container to share.', $marker)) + ->tip('Remove whenShared, so the restriction applies to every call.') + ->identifier('hypervel.restrictedAttribute') + ->build(); + } + + return $errors; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/RestrictedCallRule.php b/src/phpstan/src/Rules/Coroutine/RestrictedCallRule.php new file mode 100644 index 000000000..d0807bc91 --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/RestrictedCallRule.php @@ -0,0 +1,161 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkRestrictedCalls%')] +class RestrictedCallRule implements Rule +{ + /** + * Create a new restricted call rule instance. + */ + public function __construct( + private WorkerStateHelper $helper, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report the collected restricted calls that run in a request, job or operation. + * + * @param CollectedDataNode $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $collected = $node->get(RestrictedCallCollector::class); + + if ($collected === []) { + return []; + } + + $analysis = $this->helper->analyze($node); + $errors = []; + + foreach ($collected as $file => $nodes) { + foreach ($nodes as $calls) { + foreach ($calls as $call) { + $context = $analysis->getContext($call['function'], $file); + + if ($context === null || ($call['whenShared'] && ! $this->reachesSharedInstance($call, $analysis, $file))) { + continue; + } + + $tip = match ($call['attribute']) { + TestsOnly::class => 'Call it only from tests.', + BootOrTestsOnly::class => "Call it while the application boots, such as in a service provider's boot() method, or in a test, and keep per-request state in CoroutineContext or a scoped() binding.", + default => "Call it while the application boots, such as in a service provider's boot() method, and keep per-request state in CoroutineContext or a scoped() binding.", + }; + + $errors[] = RuleErrorBuilder::message($this->getMessage($call, $context)) + ->tip($tip . ($call['whenShared'] ? ' Or call it on an instance of your own.' : '')) + ->identifier('hypervel.restrictedCall') + ->file($file) + ->line($call['line']) + ->build(); + } + } + } + + // Reused errors stay valid only while the booted application and the classes the lifetimes + // were read from are unchanged. + $analysis->track($scope); + + return $errors; + } + + /** + * Determine whether the call's receiver is the instance the container shares for the worker. + * + * A parameter counts where the container injects the shared instance on every call and the method + * still holds it, and a promoted property where the container builds the instance and injects + * the shared one into every constructor it runs. + * + * @param RestrictedCall $call + */ + private function reachesSharedInstance(array $call, WorkerStateAnalysis $analysis, string $file): bool + { + $receiver = $call['receiver']; + $function = $call['function']; + $lifetimes = $analysis->lifetimes; + + if (isset($receiver['abstracts'])) { + return array_all($receiver['abstracts'], static fn (string $abstract): bool => $lifetimes->sharesAbstract($abstract)->yes()); + } + + if (isset($receiver['parameter'])) { + return $function['class'] !== null + && $function['method'] !== null + && ! $analysis->isReassigned($function, $receiver['parameter'], $file) + && $lifetimes->sharesInjectedParameter($function['class'], $function['method'], $receiver['parameter'])->yes(); + } + + return isset($receiver['property']) && $analysis->sharesPromotedProperty($function, ...$receiver['property']); + } + + /** + * Get the message for a restricted call in the context, with the receiver and arguments that + * make it restricted. + * + * @param RestrictedCall $call + */ + private function getMessage(array $call, string $context): string + { + $condition = ''; + + if ($call['condition'] !== null) { + [$kind, $parameter] = $call['condition']; + + $condition = sprintf(' %swith %s$%s', $call['may'] ? 'possibly ' : '', $kind === 'whenNotDefault' ? 'a non-default ' : '', $parameter); + } + + return sprintf( + 'Call to #[%s] method %s()%s%s %s.', + class_basename($call['attribute']), + $call['method'], + $call['whenShared'] ? ' on the worker-shared instance' : '', + $condition, + WorkerStateAnalysis::describeContext($context), + ); + } +} diff --git a/src/phpstan/src/Rules/Coroutine/SwallowedCancellationRule.php b/src/phpstan/src/Rules/Coroutine/SwallowedCancellationRule.php new file mode 100644 index 000000000..0fd0ebb51 --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/SwallowedCancellationRule.php @@ -0,0 +1,93 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkCancellationHandling%')] +class SwallowedCancellationRule implements Rule +{ + /** + * Create a new swallowed cancellation rule instance. + */ + public function __construct( + private WorkerStateHelper $helper, + ) { + } + + /** + * Get the node type the rule inspects. + */ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + /** + * Report the collected catches that run in a request, job or operation. + * + * @param CollectedDataNode $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $collected = $node->get(SwallowedCancellationCollector::class); + + if ($collected === []) { + return []; + } + + $analysis = $this->helper->analyze($node); + $errors = []; + + foreach ($collected as $file => $catches) { + foreach ($catches as $catch) { + $context = $analysis->getContext($catch['function'], $file); + + if ($context === null) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'This catch swallows the cancellation of %s in its try block %s.', + $catch['operation'], + WorkerStateAnalysis::describeContext($context), + )) + ->tip('Rethrow the exception, or rethrow it when Coroutine::causedByCancellation() is true, so the canceled work stops.') + ->identifier('hypervel.cancellation.swallowed') + ->file($file) + ->line($catch['line']) + ->build(); + } + } + + // Reused errors stay valid only while the booted application and the classes the lifetimes + // were read from are unchanged. + $analysis->track($scope); + + return $errors; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/SwooleTableInProviderRule.php b/src/phpstan/src/Rules/Coroutine/SwooleTableInProviderRule.php new file mode 100644 index 000000000..c731d3077 --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/SwooleTableInProviderRule.php @@ -0,0 +1,85 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkSwooleTables%')] +class SwooleTableInProviderRule implements Rule +{ + /** + * Create a new Swoole table in provider rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return New_::class; + } + + /** + * Report a table created in a provider's register() or boot() body. + * + * @param New_ $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $method = strtolower($scope->getFunction()?->getName() ?? ''); + + if (! $node->class instanceof Name + || ! in_array($method, ['register', 'boot'], true) + || $scope->isInAnonymousFunction() + || $scope->getClassReflection()?->is(ServiceProvider::class) !== true + ) { + return []; + } + + $class = $scope->resolveName($node->class); + + if (! $this->reflectionProvider->hasClass($class) || ! $this->reflectionProvider->getClass($class)->is(Table::class)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('A Swoole table created in a service provider\'s %s() is allocated by every process that boots the application.', $method)) + ->identifier('hypervel.swooleTableInProvider') + ->tip('Create it from a BeforeServerStart listener, so only the server allocates it before starting its workers.') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/Coroutine/WorkerLifetimes.php b/src/phpstan/src/Rules/Coroutine/WorkerLifetimes.php index e1a41ffc2..0ad3591ce 100644 --- a/src/phpstan/src/Rules/Coroutine/WorkerLifetimes.php +++ b/src/phpstan/src/Rules/Coroutine/WorkerLifetimes.php @@ -23,9 +23,37 @@ use Hypervel\Contracts\Container\SelfBuilding; use Hypervel\Contracts\Container\Transient; use Hypervel\Contracts\Http\Kernel; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Contracts\Routing\UrlRoutable; +use Hypervel\Core\Events\OnClose; +use Hypervel\Core\Events\OnConnect; +use Hypervel\Core\Events\OnFinish; +use Hypervel\Core\Events\OnPacket; +use Hypervel\Core\Events\OnPipeMessage; +use Hypervel\Core\Events\OnReceive; +use Hypervel\Core\Events\OnTask; use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\HttpServer\Events\RequestCanceled; +use Hypervel\HttpServer\Events\RequestHandled; +use Hypervel\HttpServer\Events\RequestReceived; +use Hypervel\HttpServer\Events\RequestTerminated; +use Hypervel\HttpServer\Events\ResponseSent; +use Hypervel\PHPStan\Reflection\ReflectionHelper; +use Hypervel\Queue\Attributes\DebounceFor; +use Hypervel\Queue\Events\JobAttempted; +use Hypervel\Queue\Events\JobExceptionOccurred; +use Hypervel\Queue\Events\JobFailed; +use Hypervel\Queue\Events\JobProcessed; +use Hypervel\Queue\Events\JobProcessing; +use Hypervel\Queue\Events\JobReleasedAfterException; +use Hypervel\Queue\Events\JobTimedOut; +use Hypervel\Queue\InteractsWithQueue; +use Hypervel\Routing\Events\PreparingResponse; +use Hypervel\Routing\Events\ResponsePrepared; +use Hypervel\Routing\Events\RouteMatched; +use Hypervel\Routing\Events\Routing; use Hypervel\Routing\MiddlewareNameResolver; use Hypervel\Routing\Route; use Hypervel\Routing\Router; @@ -33,17 +61,18 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\TrinaryLogic; use ReflectionAttribute; use ReflectionFunction; use ReflectionNamedType; use ReflectionParameter; +use function array_all; use function array_any; use function array_filter; use function array_intersect; use function array_key_exists; use function array_keys; -use function array_merge; use function array_pop; use function array_unique; use function array_values; @@ -53,12 +82,14 @@ use function is_bool; use function is_object; use function is_string; +use function preg_match; use function str_contains; +use function str_starts_with; use function strtolower; /** * The worker lifetimes of one analysis: which classes the booted container shares across requests, - * which methods handle a request or run a job, and which constructor parameters it fills with fresh values. + * which methods handle a request or run a job, and which parameters it fills with fresh or shared values. * * Roots come from the booted routes, middleware, listeners and Bus handlers, and from the collected * resolutions, singletons and jobs. Each class they build is walked the way the container resolves it. @@ -79,6 +110,55 @@ class WorkerLifetimes private const string UNKNOWN = 'unknown'; + /** + * The clone of the container's queued listener that CallQueuedListener runs a job on, which keeps what its + * constructor was given. + */ + private const string COPY_CLONE = 'clone'; + + /** + * The instance the dispatcher makes of a queued listener without its constructor to read the job's options. + */ + private const string COPY_WITHOUT_CONSTRUCTOR = 'without constructor'; + + /** + * The option methods the dispatcher calls on that instance whenever it queues a listener + * (Dispatcher::queueHandler() and propagateListenerOptions()). + */ + private const array LISTENER_OPTION_METHODS = [ + 'viaConnection', 'viaQueue', 'withDelay', 'backoff', 'retryUntil', 'tries', 'messageGroup', 'middleware', + ]; + + /** + * The framework events dispatched for one request, job or server operation, with the context + * their listeners run in. + */ + private const array EVENT_CONTEXTS = [ + RequestReceived::class => 'request', + RequestHandled::class => 'request', + RequestTerminated::class => 'request', + ResponseSent::class => 'request', + RequestCanceled::class => 'request', + Routing::class => 'request', + RouteMatched::class => 'request', + PreparingResponse::class => 'request', + ResponsePrepared::class => 'request', + JobProcessing::class => 'job', + JobProcessed::class => 'job', + JobFailed::class => 'job', + JobExceptionOccurred::class => 'job', + JobAttempted::class => 'job', + JobReleasedAfterException::class => 'job', + JobTimedOut::class => 'job', + OnReceive::class => 'operation', + OnPacket::class => 'operation', + OnTask::class => 'operation', + OnFinish::class => 'operation', + OnPipeMessage::class => 'operation', + OnConnect::class => 'operation', + OnClose::class => 'operation', + ]; + /** @var array */ private array $bindings; @@ -109,21 +189,42 @@ class WorkerLifetimes /** @var null|array lowercase names of the worker-shared classes and their parents */ private ?array $sharedClasses = null; - /** @var array lowercase "class::method" => the operation it handles and whether a shared instance handles it */ + /** @var array lowercase "class::method" => the operation it handles, if known, and whether a shared instance, or a clone of one, runs it */ private array $contexts = []; - /** @var list the receiver, declaring class, method, operation and whether the receiver is shared, of each context still to propagate */ + /** @var array> lowercase "class::method" => the classes of the receivers it handles an operation for */ + private array $contextReceivers = []; + + /** @var list the receiver, declaring class, method, operation, whether the receiver is shared and the kind of copy it is, if any, of each context still to propagate */ private array $pendingContexts = []; - /** @var array lowercase "class::method::parameter" => the global configuration repository is injected */ - private array $configParameters = []; + /** @var array> lowercase "class::method" => the queued listeners it handles an operation for on a copy */ + private array $copies = []; + + /** @var array lowercase "class::method" => a worker-shared instance itself handles an operation with it */ + private array $direct = []; + + /** @var array lowercase "class::method" => only copies of the worker-shared instances run it */ + private array $copyOnly = []; + + /** @var array lowercase "class::method" => a queued listener's instance made without its constructor runs it */ + private array $withoutConstructor = []; + + /** @var array lowercase "class::method::parameter" => the canonical abstract the container injects on every call it makes, or null when that is not known */ + private array $injectedParameters = []; /** @var array lowercase "class::parameter" => whether every shared instance gets its default or a fresh value */ private array $freshParameters = []; + /** @var array lowercase names of the queued classes the dispatcher resolves from the container as listeners */ + private array $queuedListeners = []; + /** @var array the classes whose declarations the lifetimes depend on */ private array $classes = []; + /** @var array the classes whose property defaults the lifetimes depend on */ + private array $defaultSources = []; + /** * Discover the worker lifetimes of the booted application and the collected roots. * @@ -131,6 +232,7 @@ class WorkerLifetimes * @param list $singletons the classes declaring #[Singleton] * @param list $queued the concrete queued classes * @param list $helperCalls the direct calls methods make on their own instance + * @param array> $observers the #[ObservedBy] observer classes, each with the model events it can handle */ public function __construct( private ReflectionProvider $reflectionProvider, @@ -139,6 +241,7 @@ public function __construct( array $singletons, array $queued, array $helperCalls, + array $observers, ) { $this->bindings = $container->getBindings(); [$this->instances, $this->scopedInstances, $this->contextual, $this->abstractAliases] = (fn (): array => [ @@ -151,6 +254,7 @@ public function __construct( $this->discoverBusHandlers(); $this->discoverHttpRoots(); $this->discoverListeners(); + $this->discoverObservers($observers); foreach ([...array_keys($this->bindings), ...array_keys($this->instances), ...$abstracts, ...$singletons] as $abstract) { $this->make($abstract, null); @@ -161,6 +265,8 @@ public function __construct( } // After every build, so whether a job instance is shared reflects every registration. + $registered = $this->contextReceivers; + foreach ($queued as $class) { $job = $this->getClass($class); @@ -169,14 +275,23 @@ public function __construct( } foreach (WorkerStateHelper::JOB_METHODS as $method) { - if ($job->hasNativeMethod($method)) { - $declaringClass = $job->getNativeMethod($method)->getDeclaringClass()->getName(); + if (! $job->hasNativeMethod($method)) { + continue; + } + + $declaringClass = $job->getNativeMethod($method)->getDeclaringClass()->getName(); + + // A method registered as this class's listener or observer already runs in the operation, + // and on the instance, its registration gives it, such as a passive observer's handle() + // during a request. + if (! isset($registered[strtolower($declaringClass . '::' . $method)][$job->getName()])) { $this->addContext($job->getName(), $declaringClass, $method, 'job', isset($this->receivers[$job->getName()])); } } } $this->propagateContexts($helperCalls); + $this->discoverCopyOnlyMethods(); } /** @@ -200,7 +315,23 @@ public function isShared(string $class): bool } /** - * Get the operation a booted middleware or controller action method handles, or null. + * Determine whether resolving the abstract without parameters returns the instance the container + * shares for the worker. + * + * A bind() factory may return a shared object, so its resolution is unknown. + */ + public function sharesAbstract(string $abstract): TrinaryLogic + { + return match ($this->getLifetime($this->container->getAlias($abstract), true)[0]) { + self::SHARED => TrinaryLogic::createYes(), + self::UNKNOWN => TrinaryLogic::createMaybe(), + default => TrinaryLogic::createNo(), + }; + } + + /** + * Get the operation a booted middleware, controller action, job, listener or observer method + * handles, or null. */ public function getContext(string $class, string $method): ?string { @@ -208,21 +339,75 @@ public function getContext(string $class, string $method): ?string } /** - * Determine whether a worker-shared instance handles the booted middleware or controller action - * method, rather than only scoped or fresh ones. + * Determine whether a worker-shared instance handles the booted handler method, rather than only + * scoped or fresh ones. */ public function isContextShared(string $class, string $method): bool { return $this->contexts[strtolower($class . '::' . $method)][1] ?? false; } + /** + * Determine whether only copies of a queued listener run the method: the clone CallQueuedListener makes of + * the container's instance for each job, or the instance the dispatcher makes without its constructor to + * read the job's options. Each has its own properties and arrays, but a clone shares the objects they hold, + * so only writes that replace them stay with the copy. + */ + public function runsOnCopy(string $class, string $method): bool + { + return isset($this->copyOnly[strtolower($class . '::' . $method)]); + } + /** * Determine whether the container injects the global configuration repository into the parameter - * of a booted controller action or a job's handler. + * of a booted controller action, a job's handler or a constructor it runs. */ public function isConfigParameter(string $class, string $method, string $parameter): bool { - return isset($this->configParameters[strtolower($class . '::' . $method . '::' . $parameter)]); + $abstract = $this->injectedParameters[strtolower($class . '::' . $method . '::' . $parameter)] ?? null; + + return $abstract !== null && $this->isGlobalConfig($abstract); + } + + /** + * Determine whether the container injects the instance it shares for the worker into the parameter + * of a booted controller action, a job's handler or a constructor it runs. + */ + public function sharesInjectedParameter(string $class, string $method, string $parameter): TrinaryLogic + { + $key = strtolower($class . '::' . $method . '::' . $parameter); + + if (! array_key_exists($key, $this->injectedParameters)) { + return TrinaryLogic::createNo(); + } + + return $this->injectedParameters[$key] === null ? TrinaryLogic::createMaybe() : $this->sharesAbstract($this->injectedParameters[$key]); + } + + /** + * Determine whether the container built every instance the method handles an operation for, so + * the properties its constructor promotes hold what the container injected. + * + * A queued class is restored from its payload, except one the dispatcher resolves as a listener or + * observer. A method no booted handler reaches is judged by its own class. One that a queued listener's + * instance made without its constructor runs may initialize a readonly promoted property itself. + */ + public function isContainerBuilt(string $class, string $method): bool + { + $key = strtolower($class . '::' . $method); + + if (isset($this->withoutConstructor[$key])) { + return false; + } + + $receivers = array_keys($this->contextReceivers[$key] ?? [$class => true]); + + return array_all($receivers, function (string $receiver): bool { + $reflection = $this->getClass($receiver); + + return $reflection !== null + && (! $reflection->is(ShouldQueue::class) || isset($this->queuedListeners[strtolower($reflection->getName())])); + }); } /** @@ -265,6 +450,16 @@ public function getClasses(): array return array_keys($this->classes); } + /** + * Get the classes whose property defaults the lifetimes were read from, which aren't declarations. + * + * @return list + */ + public function getDefaultSources(): array + { + return array_keys($this->defaultSources); + } + /** * Discover the Bus command handlers, which are resolved from the container and called directly * with their commands. @@ -395,20 +590,23 @@ private function discoverRoute(Route $route, Router $router): void // ResolvesRouteDependencies::transformDependency() skips a class a route binding may supply, // and never resolves a parameter with a default. ImplicitRouteBinding only binds a // UrlRoutable parameter named like a route parameter, or its snake case. - if ($class === null + $injected = $class === null || $this->getContextualAttribute($parameter) !== null || $parameter->isDefaultValueAvailable() || $bound || ($this->getClass($class)?->is(UrlRoutable::class) === true - && array_intersect([$parameter->getName(), Str::snake($parameter->getName())], $names) !== [])) { - continue; - } + && array_intersect([$parameter->getName(), Str::snake($parameter->getName())], $names) !== []) + ? null + : $class; - if ($actionClass !== null && $this->isGlobalConfig($class)) { - $this->configParameters[strtolower($actionClass . '::' . $method . '::' . $parameter->getName())] = true; + // Another route may run the same action with other bindings. + if ($actionClass !== null) { + $this->recordInjection($actionClass, $method, $parameter->getName(), $injected); } - $this->make($class, null); + if ($injected !== null) { + $this->make($injected, null); + } } } @@ -426,15 +624,29 @@ private function makeHandler(ClassReflection $class): array } /** - * Record the operation a method handles for a receiver, and whether a worker-shared instance - * handles it, then queue it so the helpers it calls on the receiver handle the operation too. + * Record the operation a method handles for a receiver, if known, whether a worker-shared instance + * runs it and whether it runs on a copy of a queued listener, then queue it so the helpers it calls + * on the receiver share that. + * + * @param null|self::COPY_* $copy */ - private function addContext(string $receiver, string $declaringClass, string $method, string $context, bool $shared): void + private function addContext(string $receiver, string $declaringClass, string $method, ?string $context, bool $shared, ?string $copy = null): void { $key = strtolower($declaringClass . '::' . $method); - $this->contexts[$key] = [$context, $shared || ($this->contexts[$key][1] ?? false)]; - $this->pendingContexts[] = [$receiver, $declaringClass, $method, $context, $shared]; + $this->contexts[$key] = [$context ?? $this->contexts[$key][0] ?? null, $shared || ($this->contexts[$key][1] ?? false)]; + $this->contextReceivers[$key][$receiver] = true; + $this->pendingContexts[] = [$receiver, $declaringClass, $method, $context, $shared, $copy]; + + if ($copy !== null) { + $this->copies[$key][$receiver] = true; + } elseif ($shared) { + $this->direct[$key] = true; + } + + if ($copy === self::COPY_WITHOUT_CONSTRUCTOR) { + $this->withoutConstructor[$key] = true; + } } /** @@ -452,12 +664,12 @@ private function propagateContexts(array $helperCalls): void } // Whether each receiver's method was visited as shared: a shared visit covers a later - // unshared one, but not the other way round. + // unshared one, but not the other way round. Each operation, and each kind of copy, is separate. $visited = []; while (($pending = array_pop($this->pendingContexts)) !== null) { - [$receiver, $declaringClass, $method, $context, $shared] = $pending; - $key = strtolower($receiver . "\0" . $declaringClass . '::' . $method); + [$receiver, $declaringClass, $method, $context, $shared, $copy] = $pending; + $key = strtolower($receiver . "\0" . $declaringClass . '::' . $method . "\0" . $context . "\0" . $copy); if (array_key_exists($key, $visited) && ($visited[$key] || ! $shared)) { continue; @@ -469,7 +681,7 @@ private function propagateContexts(array $helperCalls): void $helper = $this->resolveHelper($receiver, $declaringClass, $callee); if ($helper !== null) { - $this->addContext($receiver, $helper->getDeclaringClass()->getName(), $helper->getName(), $context, $shared); + $this->addContext($receiver, $helper->getDeclaringClass()->getName(), $helper->getName(), $context, $shared, $copy); } } } @@ -506,7 +718,37 @@ private function resolveHelper(string $receiver, string $callerClass, string $na } /** - * Discover the class-string event listeners, which the dispatcher resolves from the container. + * Find the methods only copies of queued listeners run: no worker-shared instance runs one itself, and every + * other worker-shared class running the same declaration is one of those listeners. A parent's helper stays + * shared when the container also shares the parent or another subclass that runs it. + */ + private function discoverCopyOnlyMethods(): void + { + foreach ($this->copies as $key => $listeners) { + [$class, $method] = explode('::', $key, 2); + $declaringClass = isset($this->direct[$key]) ? null : $this->getClass($class); + + if ($declaringClass === null) { + continue; + } + + $runsElsewhere = array_any(array_keys($this->receivers), function (string $receiver) use ($listeners, $declaringClass, $method): bool { + $reflection = isset($listeners[$receiver]) ? null : $this->getClass($receiver); + + return $reflection !== null + && $reflection->is($declaringClass->getName()) + && $this->resolveHelper($receiver, $declaringClass->getName(), $method)?->getDeclaringClass()->getName() === $declaringClass->getName(); + }); + + if (! $runsElsewhere) { + $this->copyOnly[$key] = true; + } + } + } + + /** + * Discover the class-string event listeners and observers, which the dispatcher resolves from the + * container, and the operations each handles when its event is dispatched for one. */ private function discoverListeners(): void { @@ -516,21 +758,184 @@ private function discoverListeners(): void return; } - $listeners = [...$events->getRawListeners(), ...(fn (): array => $this->wildcards)->call($events)]; + [$wildcards, $observers, $observerWildcards, $patterns] = (fn (): array => [ + $this->wildcards, + $this->observers, + $this->observerWildcards, + $this->compiledWildcardPatterns, + ])->call($events); + + // Observers, which include listen('*') ones, are never queued and never fall back to __invoke(). + $registries = [ + [$events->getRawListeners(), false, true], + [$wildcards, true, true], + [$observers, false, false], + [$observerWildcards, true, false], + ]; + + foreach ($registries as [$registry, $wildcard, $queueable]) { + foreach ($registry as $event => $listeners) { + $contexts = $this->getEventContexts($event, $wildcard ? $patterns[$event] : null); + + foreach ($listeners as $listener) { + [$class, $method] = match (true) { + is_string($listener) => Str::parseCallback($listener, 'handle'), + is_array($listener) && is_string($listener[0] ?? null) => [$listener[0], $listener[1] ?? null], + default => [null, null], + }; + + $reflection = $class === null ? null : $this->getClass($class); + + if ($reflection === null) { + continue; + } + + if (! is_string($method)) { + $this->make($reflection->getName(), null); - foreach (array_merge(...array_values($listeners)) as $listener) { - $class = match (true) { - is_string($listener) => explode('@', $listener, 2)[0], - is_array($listener) && is_string($listener[0] ?? null) => $listener[0], - default => null, - }; + continue; + } + + // Dispatcher::createClassCallable() falls back to __invoke(). + if ($queueable && ! $reflection->hasNativeMethod($method)) { + $method = '__invoke'; + } - if ($class !== null && $this->getClass($class) !== null) { - $this->make($class, null); + foreach ($contexts as $context) { + $this->addHandlerContext($reflection, $method, $context, $queueable); + } + } } } } + /** + * Get the operations an event name or wildcard pattern's listeners handle: those of the framework + * events it matches, or any operation for a model event. A listener of another event gets null. + * + * @return non-empty-list + */ + private function getEventContexts(int|string $event, ?string $pattern): array + { + $contexts = []; + + foreach (self::EVENT_CONTEXTS as $name => $context) { + if ($pattern === null ? $name === $event : preg_match($pattern, $name) === 1) { + $contexts[$context] = $context; + } + } + + if (is_string($event) && str_starts_with($event, 'eloquent.')) { + $contexts['operation'] = 'operation'; + } + + return $contexts === [] ? [null] : array_values($contexts); + } + + /** + * Discover the observers models register through #[ObservedBy] when they boot, whose event methods + * run during any operation that persists models. + * + * @param array> $observers each observer class with the model events it can handle + */ + private function discoverObservers(array $observers): void + { + foreach ($observers as $observer => $events) { + $reflection = $this->getClass($observer); + + if ($reflection === null) { + continue; + } + + // HasEvents::registerObserver() registers each event method the observer has as a listener. + foreach ($events as $event) { + if ($reflection->hasNativeMethod($event)) { + $this->addHandlerContext($reflection, $event, 'operation', true); + } + } + } + } + + /** + * Resolve a listener or observer as the dispatcher does and give the method it runs the operation + * its event belongs to, when that is known. + * + * A queued listener runs as a job whatever its event: CallQueuedListener resolves it from the + * container in the queue worker and calls the same method, on a clone of the resolved instance + * when the listener uses InteractsWithQueue. An observer always runs when its event is dispatched. + */ + private function addHandlerContext(ClassReflection $class, string $method, ?string $context, bool $queueable): void + { + [$shared, $receiver] = $this->makeHandler($class); + $copy = null; + + if ($class->is(ShouldQueue::class)) { + $this->queuedListeners[strtolower($receiver->getName())] = true; + + if ($queueable) { + $unique = $class->is(ShouldBeUnique::class); + $debounced = $this->isDebounced($class); + + // The dispatcher calls shouldQueue() on the resolved instance while dispatching. The unique and + // debounce locks resolve it again through CallQueuedListener for their stores, both when the + // dispatcher acquires them and when they're released. + foreach (['shouldQueue' => [$context, true], 'uniqueVia' => ['job', $unique], 'debounceVia' => ['job', $debounced]] as $hook => [$hookContext, $runs]) { + if ($runs && $receiver->hasNativeMethod($hook)) { + $this->addContext($receiver->getName(), $receiver->getNativeMethod($hook)->getDeclaringClass()->getName(), $hook, $hookContext, $shared); + } + } + + // The dispatcher reads the job's options from an instance of the registered class that it makes + // without the constructor, so a binding to another class doesn't apply. + $options = [ + ...self::LISTENER_OPTION_METHODS, + $class->hasNativeMethod('deduplicator') ? 'deduplicator' : 'deduplicationId', + ...($unique ? ['uniqueId', 'uniqueFor'] : []), + ...($debounced ? ['debounceId'] : []), + ]; + + foreach ($options as $option) { + if ($class->hasNativeMethod($option)) { + $this->addContext($class->getName(), $class->getNativeMethod($option)->getDeclaringClass()->getName(), $option, $context, false, self::COPY_WITHOUT_CONSTRUCTOR); + } + } + + $context = 'job'; + $copy = isset($receiver->getTraits(true)[InteractsWithQueue::class]) ? self::COPY_CLONE : null; + } + } + + // A visit with no known operation still records that the method runs, such as a passive observer + // of an application event running directly on the shared instance. + if ($receiver->hasNativeMethod($method)) { + $declaringClass = $receiver->getNativeMethod($method)->getDeclaringClass()->getName(); + + $this->addContext($receiver->getName(), $declaringClass, $method, $context, $shared, $copy); + } + } + + /** + * Determine whether the dispatcher debounces a queued listener, as its getAttributeValue() reads it from + * outside the listener: a #[DebounceFor] attribute, whose value is never null, or else a non-null default + * of the public debounceFor property. + */ + private function isDebounced(ClassReflection $class): bool + { + if (ReflectionHelper::findClassAttribute($class, DebounceFor::class) !== null) { + return true; + } + + if (! $class->hasNativeProperty('debounceFor')) { + return false; + } + + // A default isn't a declaration, so its sources are tracked whatever it is. + $this->defaultSources[$class->getName()] = true; + $property = $class->getNativeReflection()->getProperty('debounceFor'); + + return $property->isPublic() && ! $property->isStatic() && $property->getDefaultValue() !== null; + } + /** * Discover the dependencies the container injects into a job's handler. * @@ -560,16 +965,16 @@ private function discoverJob(string $class): void if ($dependency === null || $this->getContextualAttribute($parameter) !== null || (! $parameter->isVariadic() && $parameter->isDefaultValueAvailable() && ! $this->container->bound($dependency))) { + $this->recordInjection($declaringClass, $method, $parameter->getName(), null); + continue; } - // A contextual binding for the job may replace the repository with another one. + // A contextual binding for the job replaces the dependency; a closure's result is unknown. + // Another job inheriting the handler may inject something else. $concrete = $this->getContextualConcrete($reflection->getName(), $this->container->getAlias($dependency)); - if ($this->isGlobalConfig($dependency) && ($concrete === null || (is_string($concrete) && $this->isGlobalConfig($concrete)))) { - $this->configParameters[strtolower($declaringClass . '::' . $method . '::' . $parameter->getName())] = true; - } - + $this->recordInjection($declaringClass, $method, $parameter->getName(), $concrete === null ? $dependency : (is_string($concrete) ? $concrete : null)); $this->make($dependency, $reflection->getName()); } } @@ -586,7 +991,13 @@ private function make(string $abstract, ?string $consumer): array if ($consumer !== null && ($concrete = $this->getContextualConcrete($consumer, $abstract)) !== null) { // A contextual build is never cached; a class name is resolved on its own, a closure is unknown. - return is_string($concrete) ? $this->make($concrete, null) : [self::UNKNOWN, null]; + if (is_string($concrete)) { + return $this->make($concrete, null); + } + + $this->addFactoryBuild($abstract); + + return [self::UNKNOWN, null]; } [$lifetime, $class, $knownArguments] = $this->getLifetime($abstract, true); @@ -629,7 +1040,7 @@ private function getLifetime(string $abstract, bool $implicit): array private function computeLifetime(string $abstract, bool $implicit): array { $binding = $this->bindings[$abstract] ?? null; - $concrete = $binding === null ? null : $this->getBindingConcrete($binding['concrete']); + $concrete = $binding === null ? null : self::getBindingConcrete($binding['concrete']); if (array_key_exists($abstract, $this->instances)) { $instance = $this->instances[$abstract]; @@ -641,26 +1052,33 @@ private function computeLifetime(string $abstract, bool $implicit): array } if ($binding !== null) { - if ($concrete === null) { - return [self::UNKNOWN, null, false]; - } - $lifetime = match (true) { isset($this->scopedInstances[$abstract]) => self::SCOPED, $binding['shared'] => self::SHARED, default => self::FRESH, }; + // The container caches whatever a shared or scoped binding builds, even when the class it + // builds is unknown. A bind() binding's lifetime is the built class's, and a factory may + // return a shared object. + $unknown = [$lifetime === self::FRESH ? self::UNKNOWN : $lifetime, null, false]; + + if ($concrete === null) { + $this->addFactoryBuild($abstract); + + return $unknown; + } + if ($concrete === $abstract) { $class = $this->getBuildableClass($concrete); - return $class === null ? [self::UNKNOWN, null, false] : [$lifetime, $class->getName(), ! $class->is(SelfBuilding::class)]; + return $class === null ? $unknown : [$lifetime, $class->getName(), ! $class->is(SelfBuilding::class)]; } [$concreteLifetime, $class, $knownArguments] = $this->getLifetime($this->container->getAlias($concrete), false); if ($concreteLifetime === self::UNKNOWN) { - return [self::UNKNOWN, null, false]; + return $unknown; } return [$lifetime === self::FRESH ? $concreteLifetime : $lifetime, $class, $knownArguments]; @@ -716,7 +1134,9 @@ private function build(string $class, string $lifetime, bool $knownArguments): v $declaringClass = $constructor->getDeclaringClass()->getName(); foreach ($constructor->getParameters() as $parameter) { - $outcome = $knownArguments ? $this->resolveConstructorParameter($parameter, $class) : self::UNKNOWN; + [$outcome, $injected] = $knownArguments ? $this->resolveConstructorParameter($parameter, $class) : [self::UNKNOWN, null]; + + $this->recordInjection($declaringClass, '__construct', $parameter->getName(), $injected); if ($shared) { $parameterKey = strtolower($declaringClass . '::' . $parameter->getName()); @@ -726,45 +1146,79 @@ private function build(string $class, string $lifetime, bool $knownArguments): v } } + /** + * Record that a factory may run the class's constructor, with arguments the analysis cannot see. + */ + private function addFactoryBuild(string $class): void + { + $constructor = $this->getClass($class)?->getNativeReflection()->getConstructor(); + + if ($constructor === null) { + return; + } + + foreach ($constructor->getParameters() as $parameter) { + $this->recordInjection($constructor->getDeclaringClass()->getName(), '__construct', $parameter->getName(), null); + } + } + + /** + * Record the abstract the container injects into a parameter on one of the calls it makes. Calls + * that inject different abstracts, or an unknown one, leave it unknown. + */ + private function recordInjection(string $class, string $method, string $parameter, ?string $abstract): void + { + $key = strtolower($class . '::' . $method . '::' . $parameter); + $abstract = $abstract === null ? null : $this->container->getAlias($abstract); + + $this->injectedParameters[$key] = ! array_key_exists($key, $this->injectedParameters) || $this->injectedParameters[$key] === $abstract + ? $abstract + : null; + } + /** * Resolve a constructor parameter as Container::resolveRecipeParameters() does for the class being - * built, and return what it receives. + * built, and return what it receives with the abstract injected, when it is one known abstract. + * + * @return array{string, null|string} */ - private function resolveConstructorParameter(ReflectionParameter $parameter, string $consumer): string + private function resolveConstructorParameter(ReflectionParameter $parameter, string $consumer): array { if ($this->getContextualAttribute($parameter) !== null) { - return self::UNKNOWN; + return [self::UNKNOWN, null]; } $class = $this->getParameterClass($parameter); if ($class === null) { - return $parameter->isDefaultValueAvailable() && ! isset($this->contextual[$consumer]['$' . $parameter->getName()]) - ? self::DEFAULT - : self::UNKNOWN; + return [ + $parameter->isDefaultValueAvailable() && ! isset($this->contextual[$consumer]['$' . $parameter->getName()]) + ? self::DEFAULT + : self::UNKNOWN, + null, + ]; } if ($parameter->isDefaultValueAvailable() && ! $this->container->bound($class) && ! isset($this->contextual[$consumer][$class])) { - return self::DEFAULT; + return [self::DEFAULT, null]; } - if ($parameter->isVariadic()) { - $concrete = $this->getContextualConcrete($consumer, $this->container->getAlias($class)); + $concrete = $this->getContextualConcrete($consumer, $this->container->getAlias($class)); - if (is_array($concrete)) { - foreach ($concrete as $abstract) { - if (is_string($abstract)) { - $this->make($abstract, $consumer); - } + if ($parameter->isVariadic() && is_array($concrete)) { + foreach ($concrete as $abstract) { + if (is_string($abstract)) { + $this->make($abstract, $consumer); } - - return self::UNKNOWN; } + + return [self::UNKNOWN, null]; } - return $this->make($class, $consumer)[0]; + // A contextual class name replaces the dependency; a contextual closure's result is unknown. + return [$this->make($class, $consumer)[0], $concrete === null ? $class : (is_string($concrete) ? $concrete : null)]; } /** @@ -786,7 +1240,7 @@ private function getContextualConcrete(string $consumer, string $abstract): mixe /** * Get the class a binding's closure builds when it was registered with a class name. */ - private function getBindingConcrete(Closure $closure): ?string + public static function getBindingConcrete(Closure $closure): ?string { $reflection = new ReflectionFunction($closure); $concrete = $reflection->getStaticVariables()['concrete'] ?? null; @@ -798,20 +1252,28 @@ private function getBindingConcrete(Closure $closure): ?string } /** - * Get the class the container can build for the name, or null when it is unknown, not - * instantiable, or bound by its own Bind or BindWhen attributes. + * Determine whether the container builds the class itself: an instantiable class without its own + * Bind or BindWhen attributes. */ - private function getBuildableClass(string $name): ?ClassReflection + public static function isBuildableClass(ClassReflection $class): bool { - $class = $this->getClass($name); - - if ($class === null || ! $class->isClass() || $class->isAbstract()) { - return null; + if (! $class->isClass() || $class->isAbstract()) { + return false; } $native = $class->getNativeReflection(); - return $native->getAttributes(Bind::class) === [] && $native->getAttributes(BindWhen::class) === [] ? $class : null; + return $native->getAttributes(Bind::class) === [] && $native->getAttributes(BindWhen::class) === []; + } + + /** + * Get the class the container can build for the name, or null when it is unknown or not buildable. + */ + private function getBuildableClass(string $name): ?ClassReflection + { + $class = $this->getClass($name); + + return $class !== null && self::isBuildableClass($class) ? $class : null; } /** diff --git a/src/phpstan/src/Rules/Coroutine/WorkerStateAnalysis.php b/src/phpstan/src/Rules/Coroutine/WorkerStateAnalysis.php new file mode 100644 index 000000000..bea9d7dfb --- /dev/null +++ b/src/phpstan/src/Rules/Coroutine/WorkerStateAnalysis.php @@ -0,0 +1,274 @@ +> + */ + private array $actions = []; + + /** + * The first position each method or route action reassigns each parameter at. + * + * @var array> + */ + private array $reassigned = []; + + /** + * Build the model from the collected data and the booted container. + */ + public function __construct( + CollectedDataNode $node, + ReflectionProvider $reflectionProvider, + Container $container, + private RuntimeInputs $runtimeInputs, + ) { + $abstracts = []; + $singletons = []; + $queued = []; + $helperCalls = []; + + foreach ($node->get(WorkerStateCallCollector::class) as $file => $calls) { + foreach ($calls as $call) { + if ($call['type'] === 'resolution') { + $abstracts += array_fill_keys($call['abstracts'], true); + } elseif ($call['type'] === 'action') { + $this->actions[$file][] = $call; + } elseif ($call['type'] === 'call') { + $helperCalls[] = $call; + } + } + } + + $observers = []; + + foreach ($node->get(WorkerStateClassCollector::class) as $fileClasses) { + foreach ($fileClasses as $class) { + if ($class['singleton']) { + $singletons[] = $class['class']; + } + + if ($class['queued']) { + $queued[] = $class['class']; + } + + foreach ($class['observers'] as $observer) { + $observers[$observer] = ($observers[$observer] ?? []) + array_fill_keys($class['events'], true); + } + } + } + + foreach ($node->get(WorkerStateParameterCollector::class) as $file => $parameters) { + foreach ($parameters as $parameter) { + $key = $this->getFunctionKey($parameter['function'], $file); + $position = $parameter['function']['position']; + + if ($key !== null && $position < ($this->reassigned[$key][$parameter['parameter']] ?? PHP_INT_MAX)) { + $this->reassigned[$key][$parameter['parameter']] = $position; + } + } + } + + $this->lifetimes = new WorkerLifetimes( + $reflectionProvider, + $container, + array_keys($abstracts), + $singletons, + $queued, + $helperCalls, + array_map(array_keys(...), $observers), + ); + } + + /** + * Declare that a rule's errors depend on the booted application and the classes the lifetimes + * were read from, after the rule's queries have read them. + * + * @param DependencyTracker&Scope $scope + */ + public function track(Scope $scope): void + { + // Lifetimes come from the booted bindings, routes, middleware, listeners and Bus handlers. + $this->runtimeInputs->track($scope); + + foreach ($this->lifetimes->getClasses() as $class) { + $scope->trackClassDependency($class); + } + + foreach ($this->lifetimes->getDefaultSources() as $class) { + $this->runtimeInputs->trackSources($scope, $class, null); + } + } + + /** + * Describe when code in the context runs. + */ + public static function describeContext(string $context): string + { + return match ($context) { + 'job' => 'while running a job', + 'operation' => 'during an operation', + default => 'while handling a request', + }; + } + + /** + * Get the operation the function always handles: the route action it is, a booted handler method, + * a helper such a method or a job calls, or else its collected context. + * + * A registration is better evidence than the class alone: a queueable class's handle() registered + * as a passive observer runs synchronously during its event. + * + * @param FunctionFacts $function + */ + public function getContext(array $function, string $file): ?string + { + if ($function['closure']) { + return $this->findAction($function, $file) === null ? null : 'action'; + } + + if ($function['class'] === null || $function['method'] === null) { + return $function['context']; + } + + return $this->lifetimes->getContext($function['class'], $function['method']) ?? $function['context']; + } + + /** + * Determine whether the function gives the parameter another value before the code at its position. + * + * @param FunctionFacts $function + */ + public function isReassigned(array $function, string $parameter, string $file): bool + { + $key = $this->getFunctionKey($function, $file); + + return $key !== null && ($this->reassigned[$key][$parameter] ?? PHP_INT_MAX) < $function['position']; + } + + /** + * Determine whether the container injects the global configuration repository into the function's + * parameter, and the function still holds it: a controller action, a route closure or a job's handler. + * + * @param FunctionFacts $function + */ + public function isInjectedConfig(array $function, string $parameter, string $file): bool + { + if ($this->isReassigned($function, $parameter, $file)) { + return false; + } + + if ($function['closure']) { + $class = $this->findAction($function, $file)['configParameters'][$parameter] ?? null; + + return $class !== null && $this->lifetimes->isGlobalConfig($class); + } + + return $function['class'] !== null + && $function['method'] !== null + && $this->lifetimes->isConfigParameter($function['class'], $function['method'], $parameter); + } + + /** + * Determine whether a readonly property the constructor promotes holds the global configuration + * repository in the method's instances: the container built them and injects that repository. + * + * @param FunctionFacts $function + */ + public function isPromotedConfig(array $function, string $constructor, string $property): bool + { + return $function['class'] !== null + && $function['method'] !== null + && $this->lifetimes->isContainerBuilt($function['class'], $function['method']) + && $this->lifetimes->isConfigParameter($constructor, '__construct', $property); + } + + /** + * Determine whether a readonly property the constructor promotes holds the instance the container + * shares for the worker in the method's instances: the container built them and injects that instance. + * + * @param FunctionFacts $function + */ + public function sharesPromotedProperty(array $function, string $constructor, string $property): bool + { + return $function['class'] !== null + && $function['method'] !== null + && $this->lifetimes->isContainerBuilt($function['class'], $function['method']) + && $this->lifetimes->sharesInjectedParameter($constructor, '__construct', $property)->yes(); + } + + /** + * Find the route action the closure code runs directly in, outside any function nested in it. + * + * @param FunctionFacts $function + * + * @return null|RouteAction + */ + private function findAction(array $function, string $file): ?array + { + $position = $function['position']; + + return array_find($this->actions[$file] ?? [], static fn (array $action): bool => $position >= $action['start'] + && $position <= $action['end'] + && ! array_any($action['nested'], static fn (array $range): bool => $position >= $range[0] && $position <= $range[1])); + } + + /** + * Get the key identifying a method or route action, or null for any other function. + * + * @param FunctionFacts $function + */ + private function getFunctionKey(array $function, string $file): ?string + { + if ($function['closure']) { + $action = $this->findAction($function, $file); + + return $action === null ? null : $file . '#' . $action['start']; + } + + return $function['class'] === null || $function['method'] === null ? null : strtolower($function['class'] . '::' . $function['method']); + } +} diff --git a/src/phpstan/src/Rules/Coroutine/WorkerStateHelper.php b/src/phpstan/src/Rules/Coroutine/WorkerStateHelper.php index 50f0c4432..b7a10cfa8 100644 --- a/src/phpstan/src/Rules/Coroutine/WorkerStateHelper.php +++ b/src/phpstan/src/Rules/Coroutine/WorkerStateHelper.php @@ -4,17 +4,22 @@ namespace Hypervel\PHPStan\Rules\Coroutine; +use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Config\Repository; -use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Http\FormRequest; +use Hypervel\PHPStan\Concerns\HasContainer; +use Hypervel\PHPStan\Support\RuntimeInputs; use Hypervel\Support\Attributes\BootOnly; use Hypervel\Support\Attributes\BootOrTestsOnly; use Hypervel\Support\Attributes\TestsOnly; use Hypervel\Support\Facades\App; use Hypervel\Support\Facades\Auth; +use Hypervel\Support\Facades\Config; use Hypervel\Support\Facades\Request as RequestFacade; use PhpParser\Node; use PhpParser\Node\Arg; @@ -22,6 +27,7 @@ use PhpParser\Node\Expr\CallLike; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; @@ -29,6 +35,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\CollectedDataNode; use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\FunctionReflection; @@ -53,17 +60,19 @@ use function strtolower; /** - * Collects the boot-independent facts of the worker-state checks; WorkerStateRule applies the booted - * lifetimes to them. + * Collects the boot-independent facts of the checks that judge code by the request, job or operation + * it runs in, and builds the shared model the rules apply the booted lifetimes with. * * @phpstan-type FunctionFacts array{class: null|string, method: null|string, closure: bool, context: null|string, position: int} - * @phpstan-type ConfigWrite array{type: 'config', line: int, abstracts: null|list, parameter: null|string, function: FunctionFacts} + * @phpstan-type ConfigWrite array{type: 'config', line: int, abstracts: null|list, parameter: null|string, property: null|array{string, string}, function: FunctionFacts} * @phpstan-type RouteAction array{type: 'action', start: int, end: int, nested: list, configParameters: array} * @phpstan-type HelperCall array{type: 'call', class: string, method: string, callee: string} */ #[AutowiredService] class WorkerStateHelper { + use HasContainer; + /** * The lowercase names of the methods that run a queued class as a job. */ @@ -95,12 +104,25 @@ class WorkerStateHelper */ private const array LIFETIME_ATTRIBUTES = [BootOnly::class, TestsOnly::class, BootOrTestsOnly::class]; + /** + * The lowercase repository methods that change configuration. + */ + private const array CONFIG_WRITES = ['set', 'push', 'prepend', 'offsetset', 'offsetunset']; + + /** + * The last collected data analyzed, and its model. + * + * @var null|array{CollectedDataNode, WorkerStateAnalysis} + */ + private ?array $analysis = null; + /** * Create a new worker state helper instance. */ public function __construct( private ReflectionProvider $reflectionProvider, private CoroutineRuleHelper $coroutineRuleHelper, + private RuntimeInputs $runtimeInputs, #[AutowiredParameter(ref: '%checkWorkerSharedState%')] public readonly bool $checkWorkerSharedState, #[AutowiredParameter(ref: '%checkStaticStateMutation%')] @@ -109,13 +131,26 @@ public function __construct( public readonly bool $checkUnboundedWorkerCaches, #[AutowiredParameter(ref: '%noRuntimeConfigMutation%')] public readonly bool $noRuntimeConfigMutation, + #[AutowiredParameter(ref: '%checkRestrictedCalls%')] + public readonly bool $checkRestrictedCalls, + #[AutowiredParameter(ref: '%checkCancellationHandling%')] + public readonly bool $checkCancellationHandling, ) { } /** - * Determine whether any worker-state check is enabled. + * Determine whether any check that judges code by the operation it runs in is enabled, so the + * shared model's facts are collected. */ public function isEnabled(): bool + { + return $this->hasStateChecks() || $this->checkRestrictedCalls || $this->checkCancellationHandling; + } + + /** + * Determine whether any check of the state code keeps for the worker is enabled. + */ + public function hasStateChecks(): bool { return $this->checkWorkerSharedState || $this->checkStaticStateMutation @@ -123,6 +158,22 @@ public function isEnabled(): bool || $this->noRuntimeConfigMutation; } + /** + * Get the worker-state model of the collected data, built once for all the rules that share it. + */ + public function analyze(CollectedDataNode $node): WorkerStateAnalysis + { + if ($this->analysis === null || $this->analysis[0] !== $node) { + // The analysis always runs on the framework container: the booted application or a test's own. + /** @var Container $container */ + $container = $this->getContainer(); + + $this->analysis = [$node, new WorkerStateAnalysis($node, $this->reflectionProvider, $container, $this->runtimeInputs)]; + } + + return $this->analysis[1]; + } + /** * Get the facts of the function the scope's code at the node runs in, or null when it is a * flushState() method or one marked as boot-time or test-only. @@ -138,7 +189,7 @@ public function getFunctionFacts(Scope $scope, Node $node): ?array { $function = $scope->getFunction(); - if ($function !== null && ($function->getName() === 'flushState' || $this->hasLifetimeAttribute($function))) { + if ($function !== null && ($function->getName() === 'flushState' || $this->getLifetimeAttribute($function) !== null)) { return null; } @@ -163,11 +214,11 @@ public function getFunctionFacts(Scope $scope, Node $node): ?array } /** - * Determine whether the function is marked as running only while the application boots or in tests. + * Get the attribute marking the function as running only while the application boots or in tests. */ - public function hasLifetimeAttribute(FunctionReflection|ExtendedMethodReflection $function): bool + public function getLifetimeAttribute(FunctionReflection|ExtendedMethodReflection $function): ?AttributeReflection { - return array_any( + return array_find( $function->getAttributes(), static fn (AttributeReflection $attribute): bool => in_array($attribute->getName(), self::LIFETIME_ATTRIBUTES, true), ); @@ -191,32 +242,97 @@ public function isConfigFunction(FuncCall $call, Scope $scope): bool && strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? '') === 'config'; } + /** + * Determine whether the call changes configuration: config() with an array, a Config facade write, + * or a repository write method. + */ + public function isConfigWrite(CallLike $call, Scope $scope): bool + { + if ($call instanceof FuncCall) { + $key = $call->getArg('key', 0); + + return $this->isConfigFunction($call, $scope) + && $key !== null + && $scope->getType($key->value)->isArray()->yes(); + } + + if (! $call instanceof MethodCall && ! $call instanceof StaticCall + || ! $call->name instanceof Identifier + || ! in_array($call->name->toLowerString(), self::CONFIG_WRITES, true)) { + return false; + } + + // A method call writes configuration when its receiver is the repository, which the caller checks. + return ! $call instanceof StaticCall + || ($call->class instanceof Name + && (new ObjectType(Config::class))->isSuperTypeOf(new ObjectType($scope->resolveName($call->class)))->yes()); + } + /** * Describe the expression a configuration write goes through when it may be the global repository: - * config() without arguments, a constant container resolution (its names, which the rule resolves - * with the booted aliases), or a parameter of the innermost function typed as a repository (its - * name, which the rule checks the container injects). Null for any other expression. + * config() without arguments, a constant resolution from the application container (its names, + * which the rule resolves with the booted aliases), a parameter of the innermost function typed + * as a repository (its name, which the rule checks the container injects), or a repository + * property the constructor promotes (which the rule checks the same way). Null for any other + * expression. * - * @return null|array{abstracts: null|list, parameter: null|string} + * @return null|array{abstracts: null|list, parameter: null|string, property: null|array{string, string}} */ public function getConfigReceiver(Expr $expr, Scope $scope): ?array { if ($expr instanceof FuncCall && $expr->getArgs() === [] && $this->isConfigFunction($expr, $scope)) { - return ['abstracts' => null, 'parameter' => null]; + return ['abstracts' => null, 'parameter' => null, 'property' => null]; } - $abstracts = $expr instanceof CallLike ? $this->getResolvedAbstracts($expr, $scope) : null; + $abstracts = $expr instanceof CallLike ? $this->getApplicationResolvedAbstracts($expr, $scope) : null; if ($abstracts !== null) { - return ['abstracts' => $abstracts, 'parameter' => null]; + return ['abstracts' => $abstracts, 'parameter' => null, 'property' => null]; } $parameter = $expr instanceof Variable && is_string($expr->name) ? array_find($this->getParameters($scope), static fn (ParameterReflection $parameter): bool => $parameter->getName() === $expr->name) : null; - return $parameter !== null && $this->isConfigRepositoryType($parameter->getType()) - ? ['abstracts' => null, 'parameter' => $parameter->getName()] + if ($parameter !== null && $this->isConfigRepositoryType($parameter->getType())) { + return ['abstracts' => null, 'parameter' => $parameter->getName(), 'property' => null]; + } + + $property = $this->isConfigRepositoryType($scope->getType($expr)) ? $this->getPromotedProperty($expr, $scope) : null; + + return $property === null ? null : ['abstracts' => null, 'parameter' => null, 'property' => $property]; + } + + /** + * Get the constructor's declaring class and the property name when the expression reads $this's + * readonly property that constructor promotes, outside closures. The property then holds what + * the container injected into the constructor, when the container built the instance. + * + * @return null|array{string, string} + */ + public function getPromotedProperty(Expr $expr, Scope $scope): ?array + { + if (! $expr instanceof PropertyFetch + || ! $expr->var instanceof Variable + || $expr->var->name !== 'this' + || ! $expr->name instanceof Identifier + || $scope->isInAnonymousFunction()) { + return null; + } + + $class = $scope->getClassReflection(); + $name = $expr->name->toString(); + + if ($class === null || ! $class->hasNativeProperty($name) || ! $class->hasConstructor()) { + return null; + } + + $property = $class->getNativeProperty($name); + $constructor = $class->getConstructor()->getDeclaringClass()->getName(); + + // Only the constructor that runs for the class sets the property it promotes. + return $property->isPromoted() && $property->isReadOnly() && $property->getDeclaringClass()->getName() === $constructor + ? [$constructor, $name] : null; } @@ -239,16 +355,12 @@ public function getParameters(Scope $scope): array */ public function getResolvedAbstracts(CallLike $call, Scope $scope): ?array { - if ($call->isFirstClassCallable()) { - return null; - } - $signature = match (true) { $call instanceof FuncCall => $call->name instanceof Name ? self::RESOLVING_FUNCTIONS[strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? '')] ?? null : null, $call instanceof MethodCall => $call->name instanceof Identifier - && (new ObjectType(Container::class))->isSuperTypeOf($scope->getType($call->var))->yes() + && (new ObjectType(ContainerContract::class))->isSuperTypeOf($scope->getType($call->var))->yes() ? self::RESOLVING_METHODS[$call->name->toLowerString()] ?? null : null, $call instanceof StaticCall => $call->class instanceof Name @@ -259,7 +371,32 @@ public function getResolvedAbstracts(CallLike $call, Scope $scope): ?array default => null, }; - if ($signature === null || array_any($call->getArgs(), static fn (Arg $argument): bool => $argument->unpack)) { + return $signature === null ? null : $this->getRequestedAbstracts($call, $signature, $scope); + } + + /** + * Get the abstracts a resolution without parameters requests from a receiver known to be the + * container, such as the container a #[BindWhen] condition receives, or null for any other call. + * + * @return null|list + */ + public function getContainerResolvedAbstracts(MethodCall $call, Scope $scope): ?array + { + $signature = $call->name instanceof Identifier ? self::RESOLVING_METHODS[$call->name->toLowerString()] ?? null : null; + + return $signature === null ? null : $this->getRequestedAbstracts($call, $signature, $scope); + } + + /** + * Get the abstracts a resolving call with the given signature requests, or null when it may pass parameters or its abstract isn't a constant string. + * + * @param array{string, bool} $signature + * + * @return null|list + */ + private function getRequestedAbstracts(CallLike $call, array $signature, Scope $scope): ?array + { + if ($call->isFirstClassCallable() || array_any($call->getArgs(), static fn (Arg $argument): bool => $argument->unpack)) { return null; } @@ -277,6 +414,50 @@ public function getResolvedAbstracts(CallLike $call, Scope $scope): ?array return $abstracts === [] ? null : array_map(static fn ($abstract): string => $abstract->getValue(), $abstracts); } + /** + * Get the abstracts a resolution from the application container requests, or null for any other + * call: the resolving helpers, the App facade, or the framework's resolving method called on the + * application, such as app() or an injected application, or on Container::getInstance(). + * + * Another container, or one overriding the method, may hold other bindings than the booted ones. + * + * @return null|list + */ + public function getApplicationResolvedAbstracts(CallLike $call, Scope $scope): ?array + { + if ($call instanceof MethodCall && ! $this->isApplicationContainerCall($call, $scope)) { + return null; + } + + return $this->getResolvedAbstracts($call, $scope); + } + + /** + * Determine whether the call runs the framework container's own method on the application: any + * application instance, such as app() or an injected application, or Container::getInstance(). + * + * Another container, or one overriding the method, may hold other bindings than the booted ones. + */ + public function isApplicationContainerCall(MethodCall $call, Scope $scope): bool + { + $receiver = $call->var; + $type = $scope->getType($receiver); + + // A worker runs one application, so any application instance is the booted one. + $application = (new ObjectType(Application::class))->isSuperTypeOf($type)->yes() + || ($receiver instanceof StaticCall + && $receiver->class instanceof Name + && $receiver->name instanceof Identifier + && $receiver->name->toLowerString() === 'getinstance' + && (new ObjectType(Container::class))->isSuperTypeOf(new ObjectType($scope->resolveName($receiver->class)))->yes()); + + $method = $application && $call->name instanceof Identifier + ? $scope->getMethodReflection($type, $call->name->name) + : null; + + return $method !== null && (new ObjectType($method->getDeclaringClass()->getName()))->isSuperTypeOf(new ObjectType(Container::class))->yes(); + } + /** * Get the label of the request or operation value the type always holds, ignoring null. */ diff --git a/src/phpstan/src/Rules/Coroutine/WorkerStateRule.php b/src/phpstan/src/Rules/Coroutine/WorkerStateRule.php index 78ddb1b59..791ae3be2 100644 --- a/src/phpstan/src/Rules/Coroutine/WorkerStateRule.php +++ b/src/phpstan/src/Rules/Coroutine/WorkerStateRule.php @@ -4,44 +4,32 @@ namespace Hypervel\PHPStan\Rules\Coroutine; -use Hypervel\Container\Container; use Hypervel\PHPStan\Collectors\WorkerStateCallCollector; use Hypervel\PHPStan\Collectors\WorkerStateClassCollector; use Hypervel\PHPStan\Collectors\WorkerStateConfigOffsetCollector; -use Hypervel\PHPStan\Collectors\WorkerStateParameterCollector; +use Hypervel\PHPStan\Collectors\WorkerStateGlobalWriteCollector; use Hypervel\PHPStan\Collectors\WorkerStateWriteCollector; -use Hypervel\PHPStan\Concerns\HasContainer; -use Hypervel\PHPStan\Support\RuntimeInputs; use PhpParser\Node; use PHPStan\Analyser\DependencyTracker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\CollectedDataNode; -use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use function array_all; -use function array_any; -use function array_fill_keys; -use function array_find; -use function array_keys; use function sprintf; -use function strtolower; - -use const PHP_INT_MAX; /** - * Reports request and operation state kept in worker-shared objects, static properties and config, - * and request-keyed caches that grow for the worker's lifetime. + * Reports request and operation state kept in worker-shared objects, static properties, global variables + * and config, and request-keyed caches that grow for the worker's lifetime. * * One rule serves the four switches, because PHPStan cannot register a rule when any of several * parameters is on, and so one write gets one diagnostic: an unbounded cache before shared or static * state. Lifetimes come from the booted application, so they are applied here rather than collected. * * @phpstan-import-type FunctionFacts from WorkerStateHelper - * @phpstan-import-type RouteAction from WorkerStateHelper * * @phpstan-type Diagnostic array{identifier: string, message: string, tip: string} * @@ -50,8 +38,6 @@ #[AutowiredService] class WorkerStateRule implements Rule { - use HasContainer; - /** * How diagnostics describe each kind of request or operation state. */ @@ -75,9 +61,7 @@ class WorkerStateRule implements Rule * Create a new worker state rule instance. */ public function __construct( - private ReflectionProvider $reflectionProvider, private WorkerStateHelper $helper, - private RuntimeInputs $runtimeInputs, ) { } @@ -99,75 +83,31 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (! $this->helper->isEnabled()) { + if (! $this->helper->hasStateChecks()) { return []; } - // Lifetimes come from the booted bindings, routes, middleware, listeners and Bus handlers. - $this->runtimeInputs->track($scope); - - $classes = $node->get(WorkerStateClassCollector::class); + $analysis = $this->helper->analyze($node); + $lifetimes = $analysis->lifetimes; $configWrites = $node->get(WorkerStateConfigOffsetCollector::class); - $abstracts = []; - $singletons = []; - $queued = []; - $actions = []; - $helperCalls = []; + $errors = []; foreach ($node->get(WorkerStateCallCollector::class) as $file => $calls) { foreach ($calls as $call) { - if ($call['type'] === 'resolution') { - $abstracts += array_fill_keys($call['abstracts'], true); - } elseif ($call['type'] === 'action') { - $actions[$file][] = $call; - } elseif ($call['type'] === 'call') { - $helperCalls[] = $call; - } else { + if ($call['type'] === 'config') { $configWrites[$file][] = $call; } } } - foreach ($classes as $fileClasses) { - foreach ($fileClasses as $class) { - if ($class['singleton']) { - $singletons[] = $class['class']; - } - - if ($class['queued']) { - $queued[] = $class['class']; - } - } - } - - // The first position each method or route action reassigns each parameter at. - $reassigned = []; - - foreach ($node->get(WorkerStateParameterCollector::class) as $file => $parameters) { - foreach ($parameters as $parameter) { - $key = $this->getFunctionKey($parameter['function'], $file, $actions[$file] ?? []); - $position = $parameter['function']['position']; - - if ($key !== null && $position < ($reassigned[$key][$parameter['parameter']] ?? PHP_INT_MAX)) { - $reassigned[$key][$parameter['parameter']] = $position; - } - } - } - - // The analysis always runs on the framework container: the booted application or a test's own. - /** @var Container $container */ - $container = $this->getContainer(); - $lifetimes = new WorkerLifetimes($this->reflectionProvider, $container, array_keys($abstracts), $singletons, $queued, $helperCalls); - $errors = []; - foreach ($node->get(WorkerStateWriteCollector::class) as $file => $writes) { foreach ($writes as $write) { - $context = $this->getContext($write['function'], $actions[$file] ?? [], $lifetimes); + $context = $analysis->getContext($write['function'], $file); // A constructor parameter keeps no state when every shared build passes a fresh value // and the constructor writes it before giving it another one. $fresh = $write['parameter'] !== null - && ! $this->isReassigned($write['function'], $write['parameter'], $file, $actions[$file] ?? [], $reassigned) + && ! $analysis->isReassigned($write['function'], $write['parameter'], $file) && $lifetimes->isFreshParameter($write['class'], $write['parameter']); $diagnostic = $this->checkWrite($write, $context, $fresh ? null : $write['state'], $lifetimes); @@ -178,7 +118,44 @@ public function processNode(Node $node, Scope $scope): array } } - foreach ($classes as $file => $fileClasses) { + foreach ($node->get(WorkerStateGlobalWriteCollector::class) as $file => $collected) { + // A write through a bound global name gets what it keeps from the request value written at the same target, + // and an argument counts only where PHPStan's call handling wrote it as a by-reference argument. + $states = []; + $arguments = []; + $globalWrites = []; + + foreach ($collected as $writes) { + foreach ($writes as $write) { + match ($write['type']) { + 'value' => $states[$write['target']] = $write['state'], + 'argument' => $arguments[$write['target']] = true, + default => $globalWrites[] = $write, + }; + } + } + + foreach ($globalWrites as $write) { + if ($write['argument'] && ! isset($arguments[$write['target']])) { + continue; + } + + $state = $write['state'] ?? $states[$write['target']] ?? null; + $context = $analysis->getContext($write['function'], $file); + + $message = match (true) { + $state !== null => sprintf('Global variable %s keeps %s, which the whole worker then shares.', $write['name'], self::STATE_LABELS[$state]), + $context !== null => sprintf('Global variable %s is written %s, but global variables are shared by the whole worker.', $write['name'], WorkerStateAnalysis::describeContext($context)), + default => null, + }; + + if ($message !== null) { + $errors[] = $this->buildError(['identifier' => 'hypervel.staticStateMutation', 'message' => $message, 'tip' => self::STATIC_TIP], $file, $write['line']); + } + } + } + + foreach ($node->get(WorkerStateClassCollector::class) as $file => $fileClasses) { foreach ($fileClasses as $class) { if (! $this->helper->checkWorkerSharedState || ! $lifetimes->isShared($class['class'])) { continue; @@ -195,29 +172,30 @@ public function processNode(Node $node, Scope $scope): array if ($this->helper->noRuntimeConfigMutation) { foreach ($configWrites as $file => $writes) { foreach ($writes as $write) { - $context = $this->getContext($write['function'], $actions[$file] ?? [], $lifetimes); + $context = $analysis->getContext($write['function'], $file); - // A resolution is the global repository when the booted aliases say so, and a parameter - // where the container injects it and the function still holds it. + // A resolution is the global repository when the booted aliases say so, a parameter + // where the container injects it and the function still holds it, and a promoted + // property where the container injects it into the constructor of the instance it builds. if ($context === null || ($write['abstracts'] !== null && ! array_all($write['abstracts'], $lifetimes->isGlobalConfig(...))) - || ($write['parameter'] !== null && ! $this->isInjectedConfig($write['function'], $write['parameter'], $file, $actions[$file] ?? [], $reassigned, $lifetimes))) { + || ($write['parameter'] !== null && ! $analysis->isInjectedConfig($write['function'], $write['parameter'], $file)) + || ($write['property'] !== null && ! $analysis->isPromotedConfig($write['function'], ...$write['property']))) { continue; } $errors[] = $this->buildError([ 'identifier' => 'hypervel.runtimeConfigMutation', - 'message' => sprintf('Configuration is changed %s, which changes it for the whole worker.', $this->describeContext($context)), + 'message' => sprintf('Configuration is changed %s, which changes it for the whole worker.', WorkerStateAnalysis::describeContext($context)), 'tip' => self::CONFIG_TIP, ], $file, $write['line']); } } } - // Reused errors stay valid only while the classes the lifetimes were read from are unchanged. - foreach ($lifetimes->getClasses() as $class) { - $scope->trackClassDependency($class); - } + // Reused errors stay valid only while the booted application and the classes the lifetimes + // were read from are unchanged. + $analysis->track($scope); return $errors; } @@ -226,14 +204,18 @@ public function processNode(Node $node, Scope $scope): array * Get the diagnostic for a collected property write, choosing an unbounded cache before shared * or static state, or null when the write is fine. * - * @param array{static: bool, class: string, property: string, line: int, function: FunctionFacts, state: null|string, parameter: null|string, cacheKey: bool} $write + * @param array{static: bool, class: string, property: string, line: int, function: FunctionFacts, state: null|string, parameter: null|string, cacheKey: bool, copied: bool} $write * @param null|string $state the state the write keeps, unless it is a fresh constructor parameter * * @return null|Diagnostic */ private function checkWrite(array $write, ?string $context, ?string $state, WorkerLifetimes $lifetimes): ?array { - $shared = ! $write['static'] && $lifetimes->isShared($write['class']); + $method = $write['function']['method']; + + // A copy of a queued listener, for a job or for reading its options, has its own properties and arrays. + $copyOnly = $write['copied'] && $method !== null && $lifetimes->runsOnCopy($write['class'], $method); + $shared = ! $write['static'] && ! $copyOnly && $lifetimes->isShared($write['class']); if ($this->helper->checkUnboundedWorkerCaches && $write['cacheKey'] && ($write['static'] || $shared)) { return [ @@ -257,17 +239,17 @@ private function checkWrite(array $write, ?string $context, ?string $state, Work // other subclasses than the shared ones. $handledShared = $write['function']['closure'] || $write['function']['context'] !== null ? $shared - : $write['function']['method'] !== null && $lifetimes->isContextShared($write['class'], $write['function']['method']); + : $method !== null && ! $copyOnly && $lifetimes->isContextShared($write['class'], $method); - if ($context !== null && $handledShared && $write['function']['method'] !== null) { + if ($context !== null && $handledShared && $method !== null) { return [ 'identifier' => 'hypervel.workerSharedState', 'message' => sprintf( '%s::%s() writes $this->%s %s, but the container shares %s across concurrent requests.', $write['class'], - $write['function']['method'], + $method, $write['property'], - $this->describeContext($context), + WorkerStateAnalysis::describeContext($context), $write['class'], ), 'tip' => 'Keep per-request values in local variables or CoroutineContext.', @@ -291,7 +273,7 @@ private function checkWrite(array $write, ?string $context, ?string $state, Work 'Static property %s::$%s is written %s, but static properties are shared by the whole worker.', $write['class'], $write['property'], - $this->describeContext($context), + WorkerStateAnalysis::describeContext($context), ), 'tip' => self::STATIC_TIP, ]; @@ -329,105 +311,4 @@ private function buildError(array $diagnostic, string $file, int $line): Identif ->line($line) ->build(); } - - /** - * Get the operation the function always handles: its collected context, the route action it is, - * a booted middleware or controller action method, or a helper such a method or a job calls. - * - * @param FunctionFacts $function - * @param list $actions the file's route actions - */ - private function getContext(array $function, array $actions, WorkerLifetimes $lifetimes): ?string - { - if ($function['closure']) { - return $this->findAction($function, $actions) === null ? null : 'action'; - } - - if ($function['context'] !== null || $function['class'] === null || $function['method'] === null) { - return $function['context']; - } - - return $lifetimes->getContext($function['class'], $function['method']); - } - - /** - * Find the route action the closure code runs directly in, outside any function nested in it. - * - * @param FunctionFacts $function - * @param list $actions the file's route actions - * - * @return null|RouteAction - */ - private function findAction(array $function, array $actions): ?array - { - $position = $function['position']; - - return array_find($actions, static fn (array $action): bool => $position >= $action['start'] - && $position <= $action['end'] - && ! array_any($action['nested'], static fn (array $range): bool => $position >= $range[0] && $position <= $range[1])); - } - - /** - * Get the key identifying a method or route action, or null for any other function. - * - * @param FunctionFacts $function - * @param list $actions the file's route actions - */ - private function getFunctionKey(array $function, string $file, array $actions): ?string - { - if ($function['closure']) { - $action = $this->findAction($function, $actions); - - return $action === null ? null : $file . '#' . $action['start']; - } - - return $function['class'] === null || $function['method'] === null ? null : strtolower($function['class'] . '::' . $function['method']); - } - - /** - * Determine whether the function gives the parameter another value before the code at its position. - * - * @param FunctionFacts $function - * @param list $actions the file's route actions - * @param array> $reassigned - */ - private function isReassigned(array $function, string $parameter, string $file, array $actions, array $reassigned): bool - { - $key = $this->getFunctionKey($function, $file, $actions); - - return $key !== null && ($reassigned[$key][$parameter] ?? PHP_INT_MAX) < $function['position']; - } - - /** - * Determine whether the container injects the global configuration repository into the function's - * parameter, and the function still holds it: a controller action, a route closure or a job's handler. - * - * @param FunctionFacts $function - * @param list $actions the file's route actions - * @param array> $reassigned - */ - private function isInjectedConfig(array $function, string $parameter, string $file, array $actions, array $reassigned, WorkerLifetimes $lifetimes): bool - { - if ($this->isReassigned($function, $parameter, $file, $actions, $reassigned)) { - return false; - } - - if ($function['closure']) { - $class = $this->findAction($function, $actions)['configParameters'][$parameter] ?? null; - - return $class !== null && $lifetimes->isGlobalConfig($class); - } - - return $function['class'] !== null - && $function['method'] !== null - && $lifetimes->isConfigParameter($function['class'], $function['method'], $parameter); - } - - /** - * Describe when code in the context runs. - */ - private function describeContext(string $context): string - { - return $context === 'job' ? 'while running a job' : 'while handling a request'; - } } diff --git a/src/phpstan/src/Rules/DiscardedDateRule.php b/src/phpstan/src/Rules/DiscardedDateRule.php new file mode 100644 index 000000000..aa8fe1383 --- /dev/null +++ b/src/phpstan/src/Rules/DiscardedDateRule.php @@ -0,0 +1,223 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkDiscardedDates%')] +class DiscardedDateRule implements Rule +{ + /** + * The classes whose native methods and @method tags are Carbon's own date methods. + */ + private const array DATE_CLASSES = [ + CarbonInterface::class => true, + CarbonImmutable::class => true, + Carbon::class => true, + HypervelCarbonImmutable::class => true, + HypervelCarbon::class => true, + ]; + + /** + * The lowercase names of the date modifiers outside MODIFIER_PATTERN. + */ + private const array MODIFIERS = [ + 'average' => true, + 'change' => true, + 'midday' => true, + 'minus' => true, + 'modify' => true, + 'plus' => true, + 'rawadd' => true, + 'rawsub' => true, + 'set' => true, + 'setanchorday' => true, + 'setdate' => true, + 'setdatefrom' => true, + 'setdatetime' => true, + 'setdatetimefrom' => true, + 'setdaysfromstartofweek' => true, + 'setisodate' => true, + 'settime' => true, + 'settimefrom' => true, + 'settimefromtimestring' => true, + 'settimestamp' => true, + 'settimezone' => true, + 'setunit' => true, + 'setunitnooverflow' => true, + 'shifttimezone' => true, + 'timestamp' => true, + 'timezone' => true, + 'utc' => true, + ]; + + /** + * Match the lowercase names of the modifier families, and of each unit setter in its plain, + * plural, set and set-plural forms. + */ + private const string MODIFIER_PATTERN = '/^(?:add|sub|startof|endof|next|previous|nth|firstof|lastof|round|floor|ceil)|^(?:set)?(?:year|month|day|hour|minute|second|milli|millisecond|micro|microsecond)s?$/'; + + /** + * The lowercase names of the methods that get a value without an argument and set it with one. + */ + private const array GETTER_SETTERS = [ + 'dayofyear' => true, + 'isoweek' => true, + 'isoweekday' => true, + 'isoweekyear' => true, + 'tz' => true, + 'utcoffset' => true, + 'week' => true, + 'weekday' => true, + 'weekyear' => true, + ]; + + /** + * Match Carbon's unit-of-unit getter/setters, such as dayOfWeek() and weekOfYear(). + * + * Carbon dispatches these names case-sensitively, so this pattern does too. + */ + private const string UNIT_OF_UNIT_PATTERN = '/^[a-z]+Of[A-Z][a-z]+$/'; + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return Expression::class; + } + + /** + * Report a discarded date change. + */ + public function processNode(Node $node, Scope $scope): array + { + $call = $node->expr; + + if (! ($call instanceof MethodCall || $call instanceof NullsafeMethodCall) + || ! $call->name instanceof Identifier + || $call->isFirstClassCallable()) { + return []; + } + + $name = $call->name->toString(); + $lowerName = strtolower($name); + $modifier = isset(self::MODIFIERS[$lowerName]) || preg_match(self::MODIFIER_PATTERN, $lowerName) === 1; + + if (! $modifier && ! isset(self::GETTER_SETTERS[$lowerName]) && preg_match(self::UNIT_OF_UNIT_PATTERN, $name) !== 1) { + return []; + } + + $receiverType = $scope->getType($call->var); + + if ($call instanceof NullsafeMethodCall) { + $receiverType = TypeCombinator::removeNull($receiverType); + } + + $classes = $receiverType->getObjectClassReflections(); + + if ($classes === [] || ! array_all($classes, fn (ClassReflection $class): bool => $this->declaresDateMethod($class, $name))) { + return []; + } + + $immutable = TrinaryLogic::lazyExtremeIdentity($classes, static fn (ClassReflection $class): TrinaryLogic => match (true) { + $class->is(DateTimeImmutable::class) => TrinaryLogic::createYes(), + $class->is(DateTime::class) => TrinaryLogic::createNo(), + default => TrinaryLogic::createMaybe(), + }); + $changes = $modifier ? TrinaryLogic::createYes() : $this->setsValue($call, $classes[0], $name, $scope); + $result = $immutable->and($changes); + + if ($result->no()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + $result->yes() + ? 'The result of %s() is discarded, so the immutable date is unchanged.' + : 'The result of %s() is discarded, so any change it makes to an immutable date is lost.', + $name, + )) + ->identifier('hypervel.discardedDate') + ->tip("Immutable date methods return a new date, so assign the result where it's needed.") + ->build(), + ]; + } + + /** + * Determine whether Carbon declares the method for the class, natively or by an @method tag. + * + * A user override is judged by its own declaration. Carbon runs its tagged methods before any + * macro of the same name, so a tagged method is never a macro. + */ + private function declaresDateMethod(ClassReflection $class, string $name): bool + { + if ($class->hasNativeMethod($name)) { + return isset(self::DATE_CLASSES[$class->getNativeMethod($name)->getDeclaringClass()->getName()]); + } + + return array_any( + $class->getAncestors(), + static fn (ClassReflection $ancestor): bool => isset(self::DATE_CLASSES[$ancestor->getName()]) + && array_key_exists($name, $ancestor->getMethodTags()), + ); + } + + /** + * Determine whether a getter/setter call sets a value: its value argument is present and not null. + * + * An unpacked argument may supply the value. + */ + private function setsValue(MethodCall|NullsafeMethodCall $call, ClassReflection $class, string $name, Scope $scope): TrinaryLogic + { + // A named argument binds to the native parameter, which PHPStan renames when it merges an @method + // tag into the method, as Carbon's Week trait does for dayOfYear(). + $method = $class->hasNativeMethod($name) ? $class->getNativeMethod($name) : $class->getMethod($name, $scope); + $argument = $call->getArg($method->getOnlyVariant()->getParameters()[0]->getName(), 0); + + if ($argument === null) { + return array_any($call->getArgs(), static fn (Arg $argument): bool => $argument->unpack) + ? TrinaryLogic::createMaybe() + : TrinaryLogic::createNo(); + } + + return $scope->getType($argument->value)->isNull()->negate(); + } +} diff --git a/src/phpstan/src/Rules/ManagedTransactionRule.php b/src/phpstan/src/Rules/ManagedTransactionRule.php new file mode 100644 index 000000000..84107ab03 --- /dev/null +++ b/src/phpstan/src/Rules/ManagedTransactionRule.php @@ -0,0 +1,424 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkManagedTransactions%')] +class ManagedTransactionRule implements Rule +{ + use InspectsConnections; + + /** + * The lowercase names of the methods that control a connection's transactions. + */ + private const array CONTROL_METHODS = ['begintransaction' => true, 'commit' => true, 'rollback' => true]; + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report the calls in a transaction() callback that end the managed transaction. + * + * @param CallLike $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + // PHPStan also analyzes a nullsafe call as a method call. + if (! $node instanceof MethodCall && ! $node instanceof StaticCall) { + return []; + } + + $ownerType = $this->getTransactionReceiverType($node, $scope); + $callback = $node->getArg('callback', 0)?->value; + + if ($ownerType === null || (! $callback instanceof Closure && ! $callback instanceof ArrowFunction)) { + return []; + } + + // Calling a generator only creates it, so transaction() commits before any of the callback's body runs. + if ($this->isGenerator($callback)) { + return [ + RuleErrorBuilder::message('The transaction() callback is a generator, so none of its body runs until after transaction() commits.') + ->line($callback->getStartLine()) + ->identifier('hypervel.transactionEndedEarly') + ->tip('Run the database work in the callback itself, and return the results instead of yielding them.') + ->build(), + ]; + } + + // transaction() passes the connection itself to the callback, so its first parameter is always the owner. + $parameter = $callback->params[0]->var ?? null; + $owners = $parameter instanceof Variable && is_string($parameter->name) ? [$parameter->name => true] : []; + $owner = $this->getOwnerKey($node, $callback, $owners, $scope); + $statements = $callback instanceof Closure ? $callback->stmts : [new Return_($callback->expr)]; + $depth = 0; + $errors = []; + + foreach ($statements as $statement) { + [$calls, $conditional, $written] = $this->inspect($statement); + + // A branch, loop or try makes the depth after it unknown. + if ($calls !== [] && ($conditional || (! $statement instanceof Expression && ! $statement instanceof Return_))) { + break; + } + + foreach ($calls as $call) { + $match = $this->matchOwner($call, $owner, $owners, $ownerType, $callback, $scope); + + if ($match === null) { + continue; + } + + $method = $call->name instanceof Identifier ? $call->name->toLowerString() : ''; + $level = $method === 'rollback' ? $this->getRollBackLevel($call) : null; + + // Another connection may be this one, and other levels depend on the level the callback starts at. + if (! $match || array_any($call->getArgs(), static fn (Arg $argument): bool => $argument->unpack) || $level === false) { + break 2; + } + + if ($method === 'begintransaction') { + ++$depth; + } elseif ($level === 0) { + $errors[] = $this->buildError($call, 'rollBack(0) rolls back the transaction that transaction() manages, with every enclosing one.'); + + break 2; + } elseif ($depth > 0) { + --$depth; + } else { + $errors[] = $this->buildError($call, sprintf('%s() ends the transaction that transaction() manages before its callback returns.', $call->name instanceof Identifier ? $call->name->toString() : $method)); + } + } + + $owners = array_diff_key($owners, $written); + $owner = $owner !== null && str_starts_with($owner, '$') && isset($written[substr($owner, 1)]) ? null : $owner; + + if ($statement instanceof Return_ || ($statement instanceof Expression && $statement->expr instanceof Throw_)) { + break; + } + } + + return $errors; + } + + /** + * Get the key that identifies the transaction's connection inside the callback: the DB facade, a + * literal DB::connection() name, or a variable or `$this` property the callback receives; null when + * it is unknown. + * + * @param array $owners + */ + private function getOwnerKey(MethodCall|StaticCall $call, Closure|ArrowFunction $callback, array $owners, Scope $scope): ?string + { + if ($call instanceof StaticCall) { + return DB::class; + } + + $receiver = $call->var; + + if ($receiver instanceof Variable && is_string($receiver->name) && ! isset($owners[$receiver->name])) { + return $this->receives($callback, $receiver->name) ? '$' . $receiver->name : null; + } + + $property = $this->getPropertyKey($receiver, $scope); + + if ($property !== null) { + return $this->receives($callback, 'this') ? '$' . $property : null; + } + + return $this->getConnectionName($receiver, $scope); + } + + /** + * Determine whether the callback sees the variable from the scope it is created in. + */ + private function receives(Closure|ArrowFunction $callback, string $name): bool + { + if ($name === 'this') { + return ! $callback->static; + } + + if ($callback instanceof ArrowFunction) { + return ! array_any($callback->params, static fn (Param $parameter): bool => $parameter->var instanceof Variable && $parameter->var->name === $name); + } + + return array_any($callback->uses, static fn (ClosureUse $use): bool => $use->var->name === $name); + } + + /** + * Determine whether the callback is a generator: it yields in its own body, not in a function or class nested in it. + */ + private function isGenerator(Closure|ArrowFunction $callback): bool + { + $visitor = new class extends NodeVisitorAbstract { + public bool $yields = false; + + public function enterNode(Node $node): ?int + { + if ($node instanceof FunctionLike || $node instanceof ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + + if ($node instanceof Yield_ || $node instanceof YieldFrom) { + $this->yields = true; + + return NodeVisitor::STOP_TRAVERSAL; + } + + return null; + } + }; + + (new NodeTraverser($visitor))->traverse($callback instanceof Closure ? $callback->stmts : [$callback->expr]); + + return $visitor->yields; + } + + /** + * Collect a statement's transaction-control calls in order, whether any of them runs conditionally, + * and the variables and `$this` properties it assigns. Nested functions and classes are skipped. + * + * @return array{list, bool, array} + */ + private function inspect(Stmt $statement): array + { + $visitor = new class(self::CONTROL_METHODS) extends NodeVisitorAbstract { + /** @var list */ + public array $calls = []; + + public bool $conditional = false; + + /** @var list */ + public array $targets = []; + + private int $conditions = 0; + + /** + * @param array $controlMethods + */ + public function __construct( + private array $controlMethods, + ) { + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof FunctionLike || $node instanceof ClassLike) { + return NodeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + + if ($this->isConditional($node)) { + ++$this->conditions; + } + + if (($node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof StaticCall) + && $node->name instanceof Identifier + && isset($this->controlMethods[$node->name->toLowerString()]) + && ! $node->isFirstClassCallable()) { + $this->calls[] = $node; + $this->conditional = $this->conditional || $this->conditions > 0; + } + + if ($node instanceof Assign || $node instanceof AssignRef || $node instanceof AssignOp) { + $this->targets[] = $node->var; + } elseif ($node instanceof Foreach_) { + $this->targets[] = $node->keyVar; + $this->targets[] = $node->valueVar; + } + + return null; + } + + public function leaveNode(Node $node): null + { + if ($this->isConditional($node)) { + --$this->conditions; + } + + return null; + } + + private function isConditional(Node $node): bool + { + return $node instanceof Ternary + || $node instanceof Match_ + || $node instanceof BooleanAnd + || $node instanceof BooleanOr + || $node instanceof LogicalAnd + || $node instanceof LogicalOr + || $node instanceof Coalesce + || $node instanceof AssignOp\Coalesce; + } + }; + + (new NodeTraverser($visitor))->traverse([$statement]); + + $written = []; + + foreach ($visitor->targets as $target) { + foreach (CoroutineRuleHelper::getTargetLeaves($target) as $leaf) { + $key = $leaf instanceof Variable && is_string($leaf->name) ? $leaf->name : $this->getPropertyWriteKey($leaf); + + if ($key !== null) { + $written[$key] = true; + } + } + } + + return [$visitor->calls, $visitor->conditional, $written]; + } + + /** + * Determine whether a control call runs the framework's control method on the managed transaction's + * connection (true), may act on another connection or run an override (false), or is not made on a + * connection (null). + * + * @param array $owners the callback's variables known to hold the transaction's connection + * @param Type $ownerType the type of the connection or facade transaction() is called on + */ + private function matchOwner(MethodCall|NullsafeMethodCall|StaticCall $call, ?string $owner, array $owners, Type $ownerType, Closure|ArrowFunction $callback, Scope $scope): ?bool + { + // The inspection collects only calls named by an identifier. + $method = $call->name instanceof Identifier ? $call->name->toString() : ''; + + if ($call instanceof StaticCall) { + if (! $call->class instanceof Name || ! ReflectionHelper::isFacade($call->class, DB::class, 'DB')) { + return null; + } + + return $owner === DB::class && $this->runsConnectionMethod(new ObjectType($scope->resolveName($call->class)), $method, $scope); + } + + $receiver = $call->var; + + if ($receiver instanceof Variable && is_string($receiver->name)) { + if (isset($owners[$receiver->name]) || $owner === '$' . $receiver->name) { + return $this->runsConnectionMethod($ownerType, $method, $scope); + } + + // A variable the callback receives unchanged has the type it has where the callback is created. + if (! $this->receives($callback, $receiver->name)) { + return false; + } + } elseif (($property = $this->getPropertyKey($receiver, $scope)) !== null && $owner === '$' . $property) { + return $this->runsConnectionMethod($ownerType, $method, $scope); + } elseif (($name = $this->getConnectionName($receiver, $scope)) !== null) { + return $owner === $name && $this->runsConnectionMethod($scope->getType($receiver), $method, $scope); + } + + // A receiver of known classes, none of them a connection, is something else; an unknown one may be a connection. + $classes = $scope->getType($receiver)->getObjectClassReflections(); + + return $classes !== [] && ! array_any($classes, static fn (ClassReflection $class): bool => $class->is(ConnectionInterface::class)) + ? null + : false; + } + + /** + * Get the level a rollBack() call rolls back to: null for the default, 0, or false for any other or unknown level. + */ + private function getRollBackLevel(MethodCall|NullsafeMethodCall|StaticCall $call): false|int|null + { + $level = $call->getArg('toLevel', 0)?->value; + + return match (true) { + $level === null, $level instanceof ConstFetch && $level->name->toLowerString() === 'null' => null, + $level instanceof Int_ && $level->value === 0 => 0, + default => false, + }; + } + + /** + * Build the error for a call that ends the managed transaction. + */ + private function buildError(CallLike $call, string $message): IdentifierRuleError + { + return RuleErrorBuilder::message($message) + ->line($call->getStartLine()) + ->identifier('hypervel.transactionEndedEarly') + ->tip('Let transaction() commit when the callback returns, or throw an exception to roll it back.') + ->build(); + } +} diff --git a/src/phpstan/src/Rules/MigrationColumnTypeRule.php b/src/phpstan/src/Rules/MigrationColumnTypeRule.php new file mode 100644 index 000000000..fad1448be --- /dev/null +++ b/src/phpstan/src/Rules/MigrationColumnTypeRule.php @@ -0,0 +1,82 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkMigrationColumnTypes%')] +class MigrationColumnTypeRule implements Rule +{ + /** + * The message and tip for each reported Blueprint method, by lowercase name. + */ + private const array METHODS = [ + 'enum' => [ + 'enum() creates a database enum, so adding a value needs a migration.', + 'Use a string column with a PHP enum cast.', + ], + 'set' => [ + 'set() creates a database set, so adding a value needs a migration.', + 'Store the values in a jsonb() column cast with AsEnumCollection, or in a related table.', + ], + 'json' => [ + "json() creates a json column, which PostgreSQL can't index or compare like jsonb.", + 'Use jsonb() for JSON the database queries, indexes or validates, or text() for a value only read back whole. jsonb() uses the appropriate type for each database.', + ], + ]; + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * Report a column type the conventions replace. + * + * @param MethodCall $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier || ! isset(self::METHODS[$node->name->toLowerString()])) { + return []; + } + + $method = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString()); + + if ($method === null || $method->getDeclaringClass()->getName() !== Blueprint::class) { + return []; + } + + [$message, $tip] = self::METHODS[$node->name->toLowerString()]; + + return [ + RuleErrorBuilder::message($message) + ->identifier('hypervel.migrationColumnType') + ->tip($tip) + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php b/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php index 1d3b3facc..535335464 100644 --- a/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php +++ b/src/phpstan/src/Rules/NoEnvCallsOutsideOfConfigRule.php @@ -4,7 +4,6 @@ namespace Hypervel\PHPStan\Rules; -use Hypervel\Foundation\Bootstrap\LoadConfiguration; use Hypervel\PHPStan\Support\ConfigSourceResolver; use Hypervel\PHPStan\Support\RuntimeInputs; use Hypervel\Support\Env; @@ -16,10 +15,8 @@ use PhpParser\Node\Name; use PHPStan\Analyser\DependencyTracker; use PHPStan\Analyser\Scope; -use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Conditional; -use PHPStan\File\FileHelper; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; @@ -27,11 +24,8 @@ use function in_array; use function sprintf; -use function str_starts_with; use function strtolower; -use const DIRECTORY_SEPARATOR; - /** * Catches environment reads outside of the config directory. * @@ -39,10 +33,6 @@ * env helper and Env reader is matched here. Raw repository, getenv() and superglobal * access are deliberate low-level reads and are not. * - * Besides the application's config directories, the framework's config and config:publish - * stub directories and package config files are config files: those providers merge, and - * those they publish into the config directory. - * * @implements Rule */ #[Conditional(attribute: new AutowiredService, if: '%noEnvCallsOutsideOfConfig%')] @@ -59,18 +49,12 @@ class NoEnvCallsOutsideOfConfigRule implements Rule private const array METHODS = ['get', 'getarray', 'getorfail']; /** - * The single reflection files are PHPStan's normalized `--tmp-file` and - * `--instead-of` paths, which are set in editor mode. + * Create a new env calls rule instance. */ public function __construct( private ConfigSourceResolver $configSourceResolver, - private FileHelper $fileHelper, private ReflectionProvider $reflectionProvider, private RuntimeInputs $runtimeInputs, - #[AutowiredParameter] - private ?string $singleReflectionFile = null, - #[AutowiredParameter] - private ?string $singleReflectionInsteadOfFile = null, ) { } @@ -101,7 +85,7 @@ public function processNode(Node $node, Scope $scope): array // The booted application decides the config directories and package config files. $this->runtimeInputs->track($scope); - if (! $this->isCalledOutsideOfConfig($node, $scope)) { + if ($this->configSourceResolver->isConfigFile($scope->getFile())) { return []; } @@ -142,50 +126,12 @@ private function getReaderName(CallLike $call, Scope $scope): ?string $class = $this->reflectionProvider->getClass($className); - if (! $class->is(Env::class)) { + // An override's body is unknown, so only Env's own readers count. + if (! $class->hasNativeMethod($call->name->toString()) + || $class->getNativeMethod($call->name->toString())->getDeclaringClass()->getName() !== Env::class) { return null; } return $class->getName() . '::' . $class->getNativeMethod($call->name->toString())->getName(); } - - /** - * Determine whether the call is outside of every config directory and package config file. - */ - protected function isCalledOutsideOfConfig(CallLike $call, Scope $scope): bool - { - $file = $this->fileHelper->normalizePath($this->resolveAnalysedFile($scope->getFile())); - - $configDirectories = [ - ...$this->configSourceResolver->getConfigDirectories(), - $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath()), - $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigStubPath()), - ]; - - foreach ($configDirectories as $configDirectory) { - // Upstream's bare prefix match lets /app/config permit /app/config-sibling. - if (str_starts_with($file, $configDirectory . DIRECTORY_SEPARATOR)) { - return false; - } - } - - return ! $this->configSourceResolver->isPackageConfigFile($file); - } - - /** - * In editor mode PHPStan analyses the temporary file, so map it back to - * the file it stands in for. - */ - private function resolveAnalysedFile(string $scopeFile): string - { - if ($this->singleReflectionFile === null) { - return $scopeFile; - } - - if ($this->fileHelper->normalizePath($scopeFile) !== $this->singleReflectionFile) { - return $scopeFile; - } - - return $this->singleReflectionInsteadOfFile ?? $scopeFile; - } } diff --git a/src/phpstan/src/Rules/NoMutableDatesRule.php b/src/phpstan/src/Rules/NoMutableDatesRule.php new file mode 100644 index 000000000..499f63083 --- /dev/null +++ b/src/phpstan/src/Rules/NoMutableDatesRule.php @@ -0,0 +1,87 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%noMutableDates%')] +class NoMutableDatesRule implements Rule +{ + /** + * The lowercase names of the mutable Carbon classes. + */ + private const array MUTABLE_CLASSES = [ + 'carbon\carbon' => true, + 'hypervel\support\carbon' => true, + ]; + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report the creation of a mutable Carbon date. + */ + public function processNode(Node $node, Scope $scope): array + { + if (! ($node instanceof New_ || $node instanceof StaticCall) + || ! $node->class instanceof Name + || $node->class->isSpecialClassName() + || ! isset(self::MUTABLE_CLASSES[strtolower($node->class->toString())]) + ) { + return []; + } + + $class = $node->class->toString(); + + if ($node instanceof New_) { + $subject = 'new ' . $class; + } else { + // Finders such as make() return null when they create nothing; a void method leaves no class at all. + $type = TypeCombinator::removeFalsey($scope->getType($node)); + + if (! $node->name instanceof Identifier + || $type->getObjectClassNames() === [] + || ! (new ObjectType(Carbon::class))->isSuperTypeOf($type)->yes() + ) { + return []; + } + + $subject = $class . '::' . $node->name->toString() . '()'; + } + + return [ + RuleErrorBuilder::message(sprintf('%s creates a mutable date.', $subject)) + ->identifier('hypervel.mutableDate') + ->tip('Create dates with the Date facade, the now() and today() helpers, or CarbonImmutable.') + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/NoNativeRequestApisRule.php b/src/phpstan/src/Rules/NoNativeRequestApisRule.php new file mode 100644 index 000000000..e96cc2551 --- /dev/null +++ b/src/phpstan/src/Rules/NoNativeRequestApisRule.php @@ -0,0 +1,186 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%noNativeRequestApis%')] +class NoNativeRequestApisRule implements Rule +{ + private const string RESPONSE_TIP = "Set headers, cookies and the status on the response with its header(), cookie() and setStatusCode() methods, and read them from the response's headers."; + + private const string SESSION_TIP = 'Use the session store through $request->session(), session() or the Session facade.'; + + private const string DISCONNECT_TIP = 'Stream through a generator callback passed to response()->stream() or an IterableStreamedResponse, which stops at the first failed write after the client disconnects. Call cancelOnDisconnect() to also stop it during a wait when the connection closes.'; + + private const string INPUT_TIP = 'Read input from the request with its query(), input(), post(), cookie() and file() methods.'; + + private const string HEADER_TIP = "Read the request's headers with its header() method."; + + private const string FILTER_TIP = 'Read the value from the request with its query(), input(), post() or cookie() methods, then validate or filter that value.'; + + /** + * The tip for each reported function, by lowercase name. Other session_* functions use the session tip. + */ + private const array FUNCTIONS = [ + 'header' => self::RESPONSE_TIP, + 'header_remove' => self::RESPONSE_TIP, + 'headers_list' => self::RESPONSE_TIP, + 'headers_sent' => self::RESPONSE_TIP, + 'http_response_code' => self::RESPONSE_TIP, + 'setcookie' => self::RESPONSE_TIP, + 'setrawcookie' => self::RESPONSE_TIP, + 'getallheaders' => self::HEADER_TIP, + 'apache_request_headers' => self::HEADER_TIP, + 'filter_input' => self::FILTER_TIP, + 'filter_input_array' => self::FILTER_TIP, + 'filter_has_var' => self::FILTER_TIP, + 'connection_aborted' => self::DISCONNECT_TIP, + 'connection_status' => self::DISCONNECT_TIP, + 'ignore_user_abort' => self::DISCONNECT_TIP, + ]; + + /** + * The input type parameter of each filter function that reads SAPI input, by lowercase name. + */ + private const array FILTER_FUNCTIONS = [ + 'filter_input' => 'type', + 'filter_input_array' => 'type', + 'filter_has_var' => 'input_type', + ]; + + /** + * The tip for each reported superglobal. + */ + private const array SUPERGLOBALS = [ + '_GET' => self::INPUT_TIP, + '_POST' => self::INPUT_TIP, + '_COOKIE' => self::INPUT_TIP, + '_FILES' => self::INPUT_TIP, + '_REQUEST' => self::INPUT_TIP, + '_SESSION' => self::SESSION_TIP . ' $_SESSION is shared by every request the worker process handles.', + ]; + + /** + * Create a new native request APIs rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return Expr::class; + } + + /** + * Report a native request API call or superglobal access. + * + * @param Expr $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof Variable) { + if (! is_string($node->name) || ! isset(self::SUPERGLOBALS[$node->name])) { + return []; + } + + $message = $node->name === '_SESSION' + ? "\$_SESSION is not Hypervel's session." + : sprintf('$%s does not hold the request Hypervel is handling.', $node->name); + + return [$this->buildError($message, self::SUPERGLOBALS[$node->name])]; + } + + if (! $node instanceof FuncCall || ! $node->name instanceof Name) { + return []; + } + + // A cheap name check comes first: every function call reaches this rule. + $shortName = strtolower($node->name->getLast()); + + if (! isset(self::FUNCTIONS[$shortName]) && (! str_starts_with($shortName, 'session_') || $shortName === 'session_create_id')) { + return []; + } + + if (! $this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $function = $this->reflectionProvider->getFunction($node->name, $scope); + + // A function of the application's own namespace with the same name is not PHP's. + if (! $function->isBuiltin()) { + return []; + } + + $name = strtolower($function->getName()); + + // The filter functions read the original SAPI input for request input types. INPUT_SERVER and INPUT_ENV + // read the process, like $_SERVER and $_ENV, which aren't request state. + if (isset(self::FILTER_FUNCTIONS[$name])) { + $type = $node->getArg(self::FILTER_FUNCTIONS[$name], 0); + + if ($type === null || ! array_any( + $scope->getType($type->value)->getConstantScalarValues(), + static fn (mixed $value): bool => in_array($value, [INPUT_GET, INPUT_POST, INPUT_COOKIE], true), + )) { + return []; + } + } + + return [$this->buildError( + sprintf("%s() does not act on Hypervel's request, response or session.", $function->getName()), + self::FUNCTIONS[$name] ?? self::SESSION_TIP, + )]; + } + + /** + * Build the error with its replacement tip. + */ + private function buildError(string $message, string $tip): IdentifierRuleError + { + return RuleErrorBuilder::message($message) + ->identifier('hypervel.nativeRequestApi') + ->tip($tip) + ->build(); + } +} diff --git a/src/phpstan/src/Rules/ObserverAttributeRule.php b/src/phpstan/src/Rules/ObserverAttributeRule.php new file mode 100644 index 000000000..7f06a5a0b --- /dev/null +++ b/src/phpstan/src/Rules/ObserverAttributeRule.php @@ -0,0 +1,195 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkObserverAttributes%')] +class ObserverAttributeRule implements Rule +{ + /** + * Create a new observer attribute rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + /** + * Report the provider method's observe() registrations. + * + * @param InClassMethodNode $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! in_array(strtolower($node->getMethodReflection()->getName()), ['register', 'boot'], true) + || ! $node->getClassReflection()->is(ServiceProvider::class) + ) { + return []; + } + + $errors = []; + + foreach ($node->getOriginalNode()->stmts ?? [] as $statement) { + if (! $statement instanceof Expression) { + continue; + } + + $call = $statement->expr; + + if (! $call instanceof StaticCall + || ! $call->class instanceof Name + || $call->class->isSpecialClassName() + || ! $call->name instanceof Identifier + || $call->name->toLowerString() !== 'observe' + ) { + continue; + } + + $model = $this->getModel($call->class, $scope); + $observers = $call->getArg('classes', 0); + + if ($model === null + || $observers === null + || array_any($call->getArgs(), static fn (Arg $argument): bool => $argument->unpack) + || ! $this->isClassStringList($scope->getType($observers->value)) + ) { + continue; + } + + // Composer's installed packages decide which models are dependencies. + $this->runtimeInputs->track($scope); + + if ($this->isDependency($model)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('%s registers its observers with observe() in a service provider.', $model->getName())) + ->identifier('hypervel.observerAttribute') + ->line($call->getStartLine()) + ->tip('By convention, list them in #[ObservedBy] on the model. Subclasses inherit attribute observers, so keep observe() with an ignore when they are meant for this class only.') + ->build(); + } + + return $errors; + } + + /** + * Get the model whose own Model::observe() the call runs. + */ + private function getModel(Name $name, Scope $scope): ?ClassReflection + { + $className = $scope->resolveName($name); + + if (! $this->reflectionProvider->hasClass($className)) { + return null; + } + + $model = $this->reflectionProvider->getClass($className); + + if (! $model->is(Model::class) || $model->getNativeMethod('observe')->getDeclaringClass()->getName() !== Model::class) { + return null; + } + + return $model; + } + + /** + * Determine whether an installed Composer package declares the class, so the application cannot add an attribute to it. + * + * Packages are compared by their real directories: a path repository's package can be outside the vendor directory. + */ + private function isDependency(ClassReflection $class): bool + { + $file = $class->getFileName(); + + if ($file === null) { + return true; + } + + $file = realpath($file) ?: $file; + + foreach (InstalledVersions::getAllRawData() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if ($name === $installed['root']['name'] || ! isset($package['install_path'])) { + continue; + } + + $directory = realpath($package['install_path']); + + if ($directory !== false && str_starts_with($file, $directory . DIRECTORY_SEPARATOR)) { + return true; + } + } + } + + return false; + } + + /** + * Determine whether the type is a constant class-string or a constant list of them. + */ + private function isClassStringList(Type $type): bool + { + $values = $type->isConstantArray()->yes() ? $type->getIterableValueType() : $type; + $strings = $values->getConstantStrings(); + + return $strings !== [] && array_all($strings, static fn (ConstantStringType $string): bool => $string->isClassString()->yes()); + } +} diff --git a/src/phpstan/src/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRule.php b/src/phpstan/src/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRule.php index aae811221..d57f88c8f 100644 --- a/src/phpstan/src/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRule.php +++ b/src/phpstan/src/Rules/Queue/JobDispatchedInTransactionUsesAfterCommitRule.php @@ -13,6 +13,7 @@ use Hypervel\Database\DatabaseManager; use Hypervel\Foundation\Bus\Dispatchable; use Hypervel\PHPStan\Concerns\HasContainer; +use Hypervel\PHPStan\Concerns\InspectsConnections; use Hypervel\PHPStan\Concerns\InspectsQueuedJobs; use Hypervel\PHPStan\Reflection\ReflectionHelper; use Hypervel\PHPStan\Reflection\SourceFiles; @@ -61,7 +62,6 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use function array_key_exists; @@ -119,6 +119,7 @@ class JobDispatchedInTransactionUsesAfterCommitRule implements Rule { use HasContainer; + use InspectsConnections; use InspectsQueuedJobs; /** @@ -213,7 +214,7 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (! ($node instanceof StaticCall || $node instanceof MethodCall) || ! $this->isTransactionCall($node, $scope)) { + if (! ($node instanceof StaticCall || $node instanceof MethodCall) || $this->getTransactionReceiverType($node, $scope) === null) { return []; } @@ -281,20 +282,6 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - /** - * Determine whether the call runs a transaction: `DB::transaction()` or a connection's `transaction()`. - */ - private function isTransactionCall(StaticCall|MethodCall $node, Scope $scope): bool - { - if (! $node->name instanceof Identifier || $node->name->toLowerString() !== 'transaction') { - return false; - } - - return $node instanceof StaticCall - ? $node->class instanceof Name && $this->isFacade($node->class, DB::class, 'DB') - : (new ObjectType(ConnectionInterface::class))->isSuperTypeOf($scope->getType($node->var))->yes(); - } - /** * Get the database connection the transaction runs on, or null when it is unknown. * @@ -308,13 +295,7 @@ private function getTransactionConnection(StaticCall|MethodCall $node, Scope $sc $receiver = $node->var; - if ( - ! $receiver instanceof StaticCall - || ! $receiver->class instanceof Name - || ! $receiver->name instanceof Identifier - || $receiver->name->toLowerString() !== 'connection' - || ! $this->isFacade($receiver->class, DB::class, 'DB') - ) { + if (! $this->isFacadeConnectionCall($receiver, $scope)) { return null; } @@ -450,7 +431,7 @@ private function isDispatchCall(Node $node, Scope $scope): bool } // Unlike upstream, Bus::dispatch() is checked too: it queues the job the same way. - if ($this->isFacade($node->class, Bus::class, 'Bus')) { + if (ReflectionHelper::isFacade($node->class, Bus::class, 'Bus')) { return $node->name->toLowerString() === 'dispatch'; } @@ -481,7 +462,7 @@ private function dispatchInReceiverChain(MethodCall|NullsafeMethodCall $chainCal if (($receiver instanceof FuncCall || $receiver instanceof StaticCall) && $this->isDispatchCall($receiver, $scope)) { // Bus::dispatch() returns the handler's result, not a pending dispatch. - return $receiver instanceof StaticCall && $receiver->class instanceof Name && $this->isFacade($receiver->class, Bus::class, 'Bus') + return $receiver instanceof StaticCall && $receiver->class instanceof Name && ReflectionHelper::isFacade($receiver->class, Bus::class, 'Bus') ? null : $receiver; } @@ -500,7 +481,7 @@ private function dispatchInReceiverChain(MethodCall|NullsafeMethodCall $chainCal */ private function getDispatchedJob(FuncCall|StaticCall $dispatch, Scope $scope): ?ClassReflection { - if ($dispatch instanceof StaticCall && $dispatch->class instanceof Name && ! $this->isFacade($dispatch->class, Bus::class, 'Bus')) { + if ($dispatch instanceof StaticCall && $dispatch->class instanceof Name && ! ReflectionHelper::isFacade($dispatch->class, Bus::class, 'Bus')) { return $this->reflectionProvider->getClass($scope->resolveName($dispatch->class)); } diff --git a/src/phpstan/src/Rules/RequestDependentBindingRule.php b/src/phpstan/src/Rules/RequestDependentBindingRule.php new file mode 100644 index 000000000..cb400701f --- /dev/null +++ b/src/phpstan/src/Rules/RequestDependentBindingRule.php @@ -0,0 +1,494 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkBindWhenConditions%')] +class RequestDependentBindingRule implements Rule +{ + use HasContainer; + + /** + * The lowercase auth methods that read the current user. + */ + private const array AUTH_READS = ['user' => true, 'check' => true, 'guest' => true, 'id' => true, 'hasuser' => true]; + + /** + * The lowercase RequestContext methods that read the current request or its values. + */ + private const array REQUEST_CONTEXT_READS = ['get' => true, 'getornull' => true, 'has' => true, 'getvalue' => true, 'getorsetvalue' => true]; + + /** + * Create a new request-dependent binding rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + private WorkerStateHelper $workerStateHelper, + private ConstructorParser $constructorParser, + private CoroutineRuleHelper $coroutineRuleHelper, + private ScopeFactory $scopeFactory, + #[AutowiredParameter(ref: '@cachedRichParser')] + private Parser $parser, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * Report each #[BindWhen] condition on the class that reads request or user state. + * + * @param InClassNode $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + + foreach ($node->getOriginalNode()->attrGroups as $group) { + foreach ($group->attrs as $attribute) { + if (strtolower($scope->resolveName($attribute->name)) !== strtolower(BindWhen::class)) { + continue; + } + + $condition = $this->getCondition($attribute->args); + + if ($condition === null) { + continue; + } + + // The booted aliases decide which abstracts resolve the request. + $this->runtimeInputs->track($scope); + + $line = $this->findRequestRead($condition, $scope); + + if ($line === null) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf('The #[BindWhen] condition on %s contains a read of the current request or user, but a matched condition binds for the worker\'s lifetime.', $node->getClassReflection()->getName())) + ->identifier('hypervel.requestDependentBinding') + ->line($line) + ->tip('Conditions must depend only on state set while the application boots. Register a scoped() binding to choose an implementation per request.') + ->build(); + } + } + + return $errors; + } + + /** + * Get the condition argument of a #[BindWhen] attribute. + * + * @param array $args + */ + private function getCondition(array $args): ?Expr + { + foreach ($args as $index => $argument) { + if ($argument->name === null ? $index === 1 : $argument->name->toString() === 'condition') { + return $argument->value; + } + } + + return null; + } + + /** + * Find the line of the condition's first read of request or user state. A first-class callable's + * body is in its own declaration, so the attribute's line is reported for it. + * + * @param DependencyTracker&Scope $scope + */ + private function findRequestRead(Expr $condition, Scope $scope): ?int + { + // PHP allows only static closures and first-class callables in attributes, not arrow functions. + if ($condition instanceof Closure) { + return $this->findRead($condition->stmts, $this->getContainerVariable($condition->params), $scope)?->getStartLine(); + } + + // A named method can itself be the read, such as Auth::check(...). + if ($condition instanceof StaticCall && $condition->isFirstClassCallable() && $condition->name instanceof Identifier) { + $class = $this->getStaticClass($condition, $scope); + + if ($class !== null && $this->isStaticRead($class, $condition->name->toString())) { + return $condition->getStartLine(); + } + } + + $declaration = match (true) { + $condition instanceof FuncCall && $condition->isFirstClassCallable() => $this->findFunction($condition, $scope), + $condition instanceof StaticCall && $condition->isFirstClassCallable() => $this->findStaticMethod($condition, $scope), + default => null, + }; + + if ($declaration === null) { + return null; + } + + [$function, $file, $class] = $declaration; + + // Function bodies are not declarations, so the file the condition was read from is tracked. + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + + // Unqualified function names resolve in the declaration's namespace, not the attribute's, and + // self:: in a method to its declaring class. + $namespace = $this->getNamespace($function, $file); + $declarationScope = $this->scopeFactory->create(ScopeContext::create($file)); + + $declarationScope = $namespace === null ? $declarationScope : $declarationScope->enterNamespace($namespace); + $declarationScope = $class === null ? $declarationScope : $declarationScope->enterClass($class); + + return $this->findRead($function->stmts ?? [], $this->getContainerVariable($function->params), $declarationScope) === null + ? null + : $condition->getStartLine(); + } + + /** + * Get the variable of the condition's first parameter, which receives the container resolving the binding. + * + * @param array $params + */ + private function getContainerVariable(array $params): ?string + { + $variable = ($params[0] ?? null)?->var; + + return $variable instanceof Variable && is_string($variable->name) ? $variable->name : null; + } + + /** + * Find the first read of request or user state in a function body. The variable holding the resolving + * container loses that evidence when the body assigns it. + * + * @param array $nodes + */ + private function findRead(array $nodes, ?string $container, Scope $scope): ?Node + { + if ($container !== null && $this->coroutineRuleHelper->find($nodes, fn (Node $node): bool => $this->coroutineRuleHelper->assigns($node, $container)) !== []) { + $container = null; + } + + return $this->findReadIn($nodes, $container, $scope); + } + + /** + * Find the first read among the nodes and their children, in source order. Nested closures and arrow + * functions are searched as bodies of their own, since predicates often run callbacks, but named + * functions and classes are declarations that don't run during the condition. + * + * @param array $nodes + */ + private function findReadIn(array $nodes, ?string $container, Scope $scope): ?Node + { + foreach ($nodes as $node) { + if (! $node instanceof Node || $node instanceof Function_ || $node instanceof ClassLike) { + continue; + } + + if ($node instanceof Closure || $node instanceof ArrowFunction) { + $read = $this->findRead( + $node instanceof ArrowFunction ? [$node->expr] : $node->stmts, + $this->getCapturedContainer($node, $container), + $scope, + ); + + if ($read !== null) { + return $read; + } + + continue; + } + + if ($this->readsRequest($node, $container, $scope)) { + return $node; + } + + foreach ($node->getSubNodeNames() as $subNodeName) { + $children = $node->{$subNodeName}; + $read = $this->findReadIn(is_array($children) ? $children : [$children], $container, $scope); + + if ($read !== null) { + return $read; + } + } + } + + return null; + } + + /** + * Get the container variable inside a nested closure or arrow function: an arrow function captures it + * unless one of its parameters has the same name, and a closure only through use. + */ + private function getCapturedContainer(Closure|ArrowFunction $function, ?string $container): ?string + { + if ($container === null + || array_any($function->params, static fn (Param $param): bool => $param->var instanceof Variable && $param->var->name === $container) + ) { + return null; + } + + return $function instanceof ArrowFunction || array_any($function->uses, static fn (ClosureUse $use): bool => $use->var->name === $container) + ? $container + : null; + } + + /** + * Determine whether the node reads the current request or user: request(), the Request facade, + * RequestContext, an auth read, or resolving the request from the container. + */ + private function readsRequest(Node $node, ?string $container, Scope $scope): bool + { + if (! $node instanceof CallLike || $node->isFirstClassCallable()) { + return false; + } + + if ($node instanceof FuncCall + && $node->name instanceof Name + && strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? '') === 'request' + ) { + return true; + } + + if ($node instanceof StaticCall && $node->name instanceof Identifier) { + $class = $this->getStaticClass($node, $scope); + + if ($class !== null && $this->isStaticRead($class, $node->name->toString())) { + return true; + } + } + + if ($node instanceof MethodCall + && $node->name instanceof Identifier + && isset(self::AUTH_READS[$node->name->toLowerString()]) + && $this->isAuthSource($node->var, $scope) + ) { + return true; + } + + $abstracts = $node instanceof MethodCall && $container !== null && $node->var instanceof Variable && $node->var->name === $container + ? $this->workerStateHelper->getContainerResolvedAbstracts($node, $scope) + : $this->workerStateHelper->getApplicationResolvedAbstracts($node, $scope); + + if ($abstracts === null) { + return false; + } + + /** @var Container $application */ + $application = $this->getContainer(); + + return array_any($abstracts, static fn (string $abstract): bool => $application->getAlias($abstract) === 'request'); + } + + /** + * Determine whether the expression gives the auth manager or a guard: auth(), or a guard taken from + * it or the Auth facade. An Auth facade subclass's own guard() overrides it, so its result is unknown. + */ + private function isAuthSource(Expr $expr, Scope $scope): bool + { + if ($expr instanceof StaticCall) { + $class = $this->getStaticClass($expr, $scope); + + return $expr->name instanceof Identifier + && $expr->name->toLowerString() === 'guard' + && $class !== null + && $class->is(Auth::class) + && ! $class->hasNativeMethod('guard') + && FacadeHelper::forwardsToRoot($class); + } + + return match (true) { + $expr instanceof FuncCall => $expr->name instanceof Name + && strtolower($this->reflectionProvider->resolveFunctionName($expr->name, $scope) ?? '') === 'auth', + $expr instanceof MethodCall => $expr->name instanceof Identifier + && $expr->name->toLowerString() === 'guard' + && $this->isAuthSource($expr->var, $scope), + default => false, + }; + } + + /** + * Determine whether calling the method on the class reads request or user state: a RequestContext + * read, a Request facade call the current request runs, or an Auth facade read. A subclass's own + * declaration of the method overrides it, and a facade's own __callStatic() can forward the call + * anywhere, so either makes the call unknown. + */ + private function isStaticRead(ClassReflection $class, string $method): bool + { + $lowercase = strtolower($method); + + if ($class->is(RequestContext::class)) { + return isset(self::REQUEST_CONTEXT_READS[$lowercase]) + && $class->getNativeMethod($method)->getDeclaringClass()->getName() === RequestContext::class; + } + + if ($class->is(RequestFacade::class)) { + $request = $this->reflectionProvider->getClass(Request::class); + + // The facade's own methods manage the facade, and static request methods such as hasMacro() + // or create() don't read the current request. + return ! $class->hasNativeMethod($method) + && FacadeHelper::forwardsToRoot($class) + && ! ($request->hasNativeMethod($method) && $request->getNativeMethod($method)->isStatic()); + } + + return $class->is(Auth::class) + && isset(self::AUTH_READS[$lowercase]) + && ! $class->hasNativeMethod($method) + && FacadeHelper::forwardsToRoot($class); + } + + /** + * Get the named class the static call is made on. The scope resolves self and parent; static is late-bound, + * so its class is unknown. + */ + private function getStaticClass(StaticCall $call, Scope $scope): ?ClassReflection + { + if (! $call->class instanceof Name || $call->class->toLowerString() === 'static') { + return null; + } + + $class = $scope->resolveName($call->class); + + return $this->reflectionProvider->hasClass($class) ? $this->reflectionProvider->getClass($class) : null; + } + + /** + * Get the namespace the file declares the function or method in: that of the last namespace + * statement before it. + */ + private function getNamespace(Node $declaration, string $file): ?string + { + $namespace = null; + + foreach ($this->parser->parseFile($file) as $statement) { + if ($statement instanceof Namespace_ && $statement->getStartFilePos() < $declaration->getStartFilePos()) { + $namespace = $statement->name?->toString(); + } + } + + return $namespace; + } + + /** + * Find the declaration of the function a first-class callable names, with its file. + * + * @return null|array{Function_, string, null} + */ + private function findFunction(FuncCall $callable, Scope $scope): ?array + { + if (! $callable->name instanceof Name || ! $this->reflectionProvider->hasFunction($callable->name, $scope)) { + return null; + } + + $function = $this->reflectionProvider->getFunction($callable->name, $scope); + $file = $function->getFileName(); + + if ($file === null) { + return null; + } + + $declaration = (new NodeFinder)->findFirst( + $this->parser->parseFile($file), + static fn (Node $node): bool => $node instanceof Function_ + && strtolower($node->namespacedName?->toString() ?? '') === strtolower($function->getName()), + ); + + return $declaration instanceof Function_ ? [$declaration, $file, null] : null; + } + + /** + * Find the declaration of the static method a first-class callable names, with its file and the + * class declaring it. + * + * @return null|array{ClassMethod, string, ClassReflection} + */ + private function findStaticMethod(StaticCall $callable, Scope $scope): ?array + { + $class = $this->getStaticClass($callable, $scope); + + if ($class === null || ! $callable->name instanceof Identifier) { + return null; + } + + $declaration = $this->constructorParser->findMethod($class, $callable->name->toString()); + + return $declaration === null + ? null + : [...$declaration, $class->getNativeMethod($callable->name->toString())->getDeclaringClass()]; + } +} diff --git a/src/phpstan/src/Rules/SocialitePropertyRule.php b/src/phpstan/src/Rules/SocialitePropertyRule.php new file mode 100644 index 000000000..3cabe563e --- /dev/null +++ b/src/phpstan/src/Rules/SocialitePropertyRule.php @@ -0,0 +1,105 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkSocialiteProviders%')] +class SocialitePropertyRule implements Rule +{ + /** + * The context-backed properties, each with the framework class whose getter reads it and the methods that read and set the request's value. + */ + private const array PROPERTIES = [ + 'parameters' => [AbstractProvider::class, 'getParameters', 'with'], + 'stateless' => [AbstractProvider::class, 'isStateless', 'stateless'], + 'additionalConfig' => [AbstractProvider::class, 'getConfig', 'setConfig'], + 'clientId' => [OAuth2Provider::class, 'getClientId', 'setConfig'], + 'clientSecret' => [OAuth2Provider::class, 'getClientSecret', 'setConfig'], + 'redirectUrl' => [OAuth2Provider::class, 'getRedirectUrl', 'redirectUrl'], + 'scopes' => [OAuth2Provider::class, 'getScopes', 'setScopes'], + 'usesPKCE' => [OAuth2Provider::class, 'usesPKCE', 'enablePKCE'], + 'fields' => [FacebookProvider::class, 'getFields', 'fields'], + 'popup' => [FacebookProvider::class, 'getPopup', 'asPopup'], + 'reRequest' => [FacebookProvider::class, 'getReRequest', 'reRequest'], + 'version' => [FacebookProvider::class, 'getGraphVersion', 'usingGraphVersion'], + 'scopeKey' => [SlackProvider::class, 'getScopeKey', 'setScopeKey'], + ]; + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return PropertyFetch::class; + } + + /** + * Report a subclass reading or writing a context-backed property directly. + * + * @param PropertyFetch $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->var instanceof Variable + || $node->var->name !== 'this' + || ! $node->name instanceof Identifier + || ! isset(self::PROPERTIES[$node->name->toString()]) + || ! $scope->isInClass() + ) { + return []; + } + + $property = $node->name->toString(); + [$framework, $getter, $setter] = self::PROPERTIES[$property]; + $class = $scope->getClassReflection(); + $function = strtolower($scope->getFunction()?->getName() ?? ''); + + // The framework class owns the property, and a getter override reads the default by design. + // A constructor runs once when the provider is built, so it can only set or read the default. + if ($class->getName() === $framework + || ! $class->is($framework) + || $function === '__construct' + || $function === strtolower($getter) + ) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('$this->%s holds the provider\'s default for every request, not the current request\'s value.', $property)) + ->identifier('hypervel.socialiteProperty') + ->tip(sprintf('Read it with %s() and set it with %s(), which use the provider\'s coroutine context.', $getter, $setter)) + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/TypedConfigGetterRule.php b/src/phpstan/src/Rules/TypedConfigGetterRule.php new file mode 100644 index 000000000..454ac22d2 --- /dev/null +++ b/src/phpstan/src/Rules/TypedConfigGetterRule.php @@ -0,0 +1,142 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkTypedConfigGetters%')] +class TypedConfigGetterRule implements Rule +{ + /** + * Create a new typed config getter rule instance. + */ + public function __construct( + private ConfigHelper $configHelper, + private ConfigParser $configParser, + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report an untyped read of a config value with one type. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $isHelper = $node instanceof FuncCall + && $node->name instanceof Name + && strtolower($this->reflectionProvider->resolveFunctionName($node->name, $scope) ?? '') === 'config'; + + if (! $isHelper && $this->configHelper->getMethodName($node, $scope) !== 'get') { + return []; + } + + $key = $node->getArg('key', 0); + + if ($key === null || array_any($node->getArgs(), static fn (Arg $argument): bool => $argument->unpack)) { + return []; + } + + $keyType = $scope->getType($key->value); + + if (! $keyType->isString()->yes() || ! $keyType->isConstantScalarValue()->yes()) { + return []; + } + + // The booted application decides whether this is a package's analysis. + $this->runtimeInputs->track($scope); + + if ($this->runtimeInputs->isPackageApplication()) { + return []; + } + + $types = []; + + foreach ($keyType->getConstantStrings() as $constantString) { + $value = $this->configParser->getValue($constantString->getValue(), $scope); + + // A default applies to an absent key, so only a present key's value decides the type. + if ($value === null || ! $value[0]->yes()) { + return []; + } + + $types[] = $value[1]; + } + + $type = TypeCombinator::union(...$types); + + [$getter, $description] = match (true) { + $type->isString()->yes() => ['string', 'a string'], + $type->isInteger()->yes() => ['integer', 'an integer'], + $type->isFloat()->yes() => ['float', 'a float'], + $type->isBoolean()->yes() => ['boolean', 'a boolean'], + $type->isArray()->yes() => ['array', 'an array'], + default => [null, null], + }; + + if ($getter === null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Config key %s always holds %s, so read it with %s().', + $keyType->describe(VerbosityLevel::precise()), + $description, + $getter, + )) + ->identifier('hypervel.typedConfigGetter') + ->tip(sprintf( + 'Use config()->%1$s(), Config::%1$s() or the repository\'s %1$s(), which return the value\'s type and throw if the configuration changes it.', + $getter, + )) + ->build(), + ]; + } +} diff --git a/src/phpstan/src/Rules/UncastEnvValueRule.php b/src/phpstan/src/Rules/UncastEnvValueRule.php new file mode 100644 index 000000000..2e4590249 --- /dev/null +++ b/src/phpstan/src/Rules/UncastEnvValueRule.php @@ -0,0 +1,200 @@ + + * + * @internal + */ +#[Conditional(attribute: new AutowiredService, if: '%checkConfigEnvCasts%')] +class UncastEnvValueRule implements Rule +{ + /** + * Create a new uncast env value rule instance. + */ + public function __construct( + private ConfigSourceResolver $configSourceResolver, + private ReflectionProvider $reflectionProvider, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return LiteralArrayNode::class; + } + + /** + * Report the array's uncast env values. + * + * @param LiteralArrayNode $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + $isConfigFile = null; + + foreach ($node->getItemNodes() as $itemNode) { + $item = $itemNode->getArrayItem(); + + if ($item === null || $item->unpack) { + continue; + } + + $itemScope = $itemNode->getScope(); + + foreach ($this->findValueReads($item->value, $itemScope) as [$read, $reader]) { + $default = $read->getArg('default', 1); + $defaultType = $default === null ? null : $itemScope->getType($default->value); + + $type = match (true) { + $defaultType === null => null, + $defaultType->isBoolean()->yes() => 'a bool', + $defaultType->isInteger()->yes() => 'an int', + $defaultType->isFloat()->yes() => 'a float', + default => null, + }; + + if ($type === null) { + continue; + } + + if ($isConfigFile === null) { + // The booted application decides the config directories and package config files. + $this->runtimeInputs->track($scope); + + $isConfigFile = $this->configSourceResolver->isConfigFile($scope->getFile()); + } + + if (! $isConfigFile) { + return []; + } + + $errors[] = RuleErrorBuilder::message(sprintf('%s can return a string, but its default is %s.', $reader, $type)) + ->identifier('hypervel.uncastEnvValue') + ->line($read->getStartLine()) + ->tip("Cast it in the config file to its default's type, such as (int) env('DB_PORT', 3306). When the variable holds a name or other text, use a string default instead.") + ->build(); + } + } + + return $errors; + } + + /** + * Find the env reads whose value the expression passes through unchanged, each with its reader's name. + * + * A read's default is its value while the variable is unset, so a read in that default counts too. + * + * @return list + */ + private function findValueReads(Expr $expr, Scope $scope): array + { + if ($expr instanceof Assign) { + return $this->findValueReads($expr->expr, $scope); + } + + if ($expr instanceof Ternary) { + return [...$this->findValueReads($expr->if ?? $expr->cond, $scope), ...$this->findValueReads($expr->else, $scope)]; + } + + if ($expr instanceof Coalesce) { + return [...$this->findValueReads($expr->left, $scope), ...$this->findValueReads($expr->right, $scope)]; + } + + if (! ($expr instanceof FuncCall || $expr instanceof StaticCall)) { + return []; + } + + $reader = $this->getReaderName($expr, $scope); + + if ($reader === null) { + return []; + } + + $default = $expr->getArg('default', 1); + + return [[$expr, $reader], ...($default === null ? [] : $this->findValueReads($default->value, $scope))]; + } + + /** + * Get the name of an env() or Env::get() read with its key, or null for any other call. + */ + private function getReaderName(FuncCall|StaticCall $call, Scope $scope): ?string + { + if ($call instanceof FuncCall) { + if (! $call->name instanceof Name || strtolower($this->reflectionProvider->resolveFunctionName($call->name, $scope) ?? '') !== 'env') { + return null; + } + + $reader = 'env'; + } else { + if (! $call->class instanceof Name || ! $call->name instanceof Identifier || $call->name->toLowerString() !== 'get') { + return null; + } + + $className = $scope->resolveName($call->class); + + if (! $this->reflectionProvider->hasClass($className)) { + return null; + } + + $class = $this->reflectionProvider->getClass($className); + + // An override's body is unknown, so only Env's own get() counts. + if (! $class->hasNativeMethod('get') || $class->getNativeMethod('get')->getDeclaringClass()->getName() !== Env::class) { + return null; + } + + $reader = $class->getName() . '::get'; + } + + $key = $call->getArg('key', 0); + $keys = $key === null ? [] : $scope->getType($key->value)->getConstantStrings(); + + return $reader . '(' . (count($keys) === 1 ? "'" . $keys[0]->getValue() . "'" : '') . ')'; + } +} diff --git a/src/phpstan/src/Rules/UnsupportedCallRule.php b/src/phpstan/src/Rules/UnsupportedCallRule.php new file mode 100644 index 000000000..1d4386983 --- /dev/null +++ b/src/phpstan/src/Rules/UnsupportedCallRule.php @@ -0,0 +1,118 @@ + + * + * @internal + */ +#[AutowiredService] +class UnsupportedCallRule implements Rule +{ + /** + * Create a new unsupported call rule instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + private FacadeHelper $facadeHelper, + ) { + } + + /** + * Get the node type the rule checks. + */ + public function getNodeType(): string + { + return CallLike::class; + } + + /** + * Report each unsupported method the call can run. + * + * @param CallLike $node + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + // PHPStan also analyzes a nullsafe call as a method call. + if (! ($node instanceof MethodCall || $node instanceof StaticCall) || ! $node->name instanceof Identifier) { + return []; + } + + $errors = []; + + foreach ($this->facadeHelper->getCalledMethods($node, $node->name->toString(), $scope) as $method) { + $attribute = $this->findUnsupported($method->getAttributes()); + $declaration = $method->getDeclaringClass()->getName() . '::' . $method->getName(); + + if ($attribute === null || isset($errors[$declaration])) { + continue; + } + + // An unsupported method may reject its call through another one, as a stream's rewind() calls seek(). + if ($this->findUnsupported($scope->getFunction()?->getAttributes() ?? []) !== null) { + return []; + } + + // Exported nodes keep the attribute's argument expressions, not the values of constants they read. + foreach (SourceFiles::forAttributeArguments($this->reflectionProvider, $method->getDeclaringClass(), Unsupported::class, $method->getName()) as $file) { + if ($file !== $scope->getFile()) { + $scope->trackFileDependency($file); + } + } + + $message = sprintf('%s() is not supported by this implementation and always throws.', $declaration); + $guidance = ($attribute->getArgumentTypes()['message'] ?? null)?->getConstantStrings() ?? []; + + if (count($guidance) === 1 && $guidance[0]->getValue() !== '') { + $message .= "\n" . $guidance[0]->getValue(); + } + + $errors[$declaration] = RuleErrorBuilder::message($message)->identifier('hypervel.unsupportedCall')->build(); + } + + return array_values($errors); + } + + /** + * Find the #[Unsupported] attribute among a function's attributes. + * + * @param list $attributes + */ + private function findUnsupported(array $attributes): ?AttributeReflection + { + return array_find($attributes, static fn (AttributeReflection $attribute): bool => $attribute->getName() === Unsupported::class); + } +} diff --git a/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesRule.php b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesRule.php index f81922c69..4eabde5f1 100644 --- a/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesRule.php +++ b/src/phpstan/src/Rules/Validation/NoPipeSeparatedValidationRulesRule.php @@ -127,10 +127,6 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if ($node->isFirstClassCallable()) { - return []; - } - $entry = match (true) { $node instanceof FuncCall => $this->matchFunction($node, $scope), $node instanceof New_ => $this->matchConstructor($node, $scope), diff --git a/src/phpstan/src/RuntimeInputsResultCacheValueExtension.php b/src/phpstan/src/RuntimeInputsResultCacheValueExtension.php index 791293263..8f4c8c532 100644 --- a/src/phpstan/src/RuntimeInputsResultCacheValueExtension.php +++ b/src/phpstan/src/RuntimeInputsResultCacheValueExtension.php @@ -46,6 +46,8 @@ class RuntimeInputsResultCacheValueExtension implements ResultCacheValueExtensio private const string PRESENCE = 'exists'; + private const string DIRECTORY_PRESENCE = 'directory-exists'; + private const string DIRECTORY = 'directory'; private const string LINKED_DIRECTORY = 'linked-directory'; @@ -77,6 +79,7 @@ public function __construct( * * @param list $files files compared by content * @param list $presenceFiles files compared only by whether they exist + * @param list $presenceDirectories directories compared only by whether they exist * @param array $directories directories mapped to the fnmatch() pattern of the files compared in them, recursively * @param array $linkedDirectories directories compared as $directories are, also through their symlinked directories * @param list $listings directories compared by the names of the files in them, recursively, also through their symlinked directories @@ -86,6 +89,7 @@ public function __construct( public function createKey( array $files = [], array $presenceFiles = [], + array $presenceDirectories = [], array $directories = [], array $linkedDirectories = [], array $listings = [], @@ -97,6 +101,7 @@ public function createKey( foreach ([ self::FILE => array_map($this->relativePath(...), $files), self::PRESENCE => array_map($this->relativePath(...), $presenceFiles), + self::DIRECTORY_PRESENCE => array_map($this->relativePath(...), $presenceDirectories), self::DIRECTORY => $this->directoryEntries($directories), self::LINKED_DIRECTORY => $this->directoryEntries($linkedDirectories), self::LISTING => array_map($this->relativePath(...), $listings), @@ -169,6 +174,7 @@ public function getValue(string $key): string match ($type) { self::FILE => $parts[] = $entry . "\t" . self::fileDigest($this->absolutePath($entry)), self::PRESENCE => $parts[] = $entry . "\t" . (is_file($this->absolutePath($entry)) ? 'present' : 'missing'), + self::DIRECTORY_PRESENCE => $parts[] = $entry . "\t" . (is_dir($this->absolutePath($entry)) ? 'present' : 'missing'), self::DIRECTORY, self::LINKED_DIRECTORY => $parts[] = $entry . "\t" . $this->directoryEntryDigest($entry, $type === self::LINKED_DIRECTORY), self::LISTING => $parts[] = $entry . "\t" . self::listingDigest($this->absolutePath($entry)), self::VARIABLE => $variables[] = $entry, @@ -267,7 +273,7 @@ private function absolutePath(string $path): string /** * Get the typed entries of a key. * - * @return list + * @return list */ private function entries(string $key): array { @@ -278,7 +284,7 @@ private function entries(string $key): array $entries = []; foreach (explode("\n", $key) as $line) { - /** @var array{self::DIRECTORY|self::FILE|self::LINKED_DIRECTORY|self::LISTING|self::PRESENCE|self::VARIABLE|self::VARIABLE_PATTERN, string} $entry createKey() writes each line as a type and an entry */ + /** @var array{self::DIRECTORY|self::DIRECTORY_PRESENCE|self::FILE|self::LINKED_DIRECTORY|self::LISTING|self::PRESENCE|self::VARIABLE|self::VARIABLE_PATTERN, string} $entry createKey() writes each line as a type and an entry */ $entry = explode("\t", $line, 2); $entries[] = $entry; } diff --git a/src/phpstan/src/Support/ConfigSourceResolver.php b/src/phpstan/src/Support/ConfigSourceResolver.php index 2a48f76f2..06cfd60bf 100644 --- a/src/phpstan/src/Support/ConfigSourceResolver.php +++ b/src/phpstan/src/Support/ConfigSourceResolver.php @@ -157,6 +157,9 @@ class ConfigSourceResolver /** * Create a new resolver instance. * + * The single reflection files are PHPStan's normalized `--tmp-file` and + * `--instead-of` paths, which are set in editor mode. + * * @param list $configuredDirectories */ public function __construct( @@ -166,6 +169,10 @@ public function __construct( private ReflectionProvider $reflectionProvider, #[AutowiredParameter(ref: '%configDirectories%')] private array $configuredDirectories, + #[AutowiredParameter] + private ?string $singleReflectionFile = null, + #[AutowiredParameter] + private ?string $singleReflectionInsteadOfFile = null, ) { } @@ -329,6 +336,38 @@ public function hasUnknownWrites(): bool return $this->getProviderSources()['unknownWrites']; } + /** + * Determine whether the analyzed file is a config file. + * + * Besides the application's config directories, the framework's config and config:publish + * stub directories and package config files are config files: those providers merge, and + * those they publish into the config directory. In editor mode PHPStan analyzes a temporary + * file, which stands in for the edited one. + */ + public function isConfigFile(string $file): bool + { + $file = $this->fileHelper->normalizePath($file); + + if ($file === $this->singleReflectionFile) { + $file = $this->singleReflectionInsteadOfFile ?? $file; + } + + $configDirectories = [ + ...$this->getConfigDirectories(), + $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigPath()), + $this->fileHelper->normalizePath(LoadConfiguration::frameworkConfigStubPath()), + ]; + + foreach ($configDirectories as $configDirectory) { + // Upstream's bare prefix match lets /app/config permit /app/config-sibling. + if (str_starts_with($file, $configDirectory . DIRECTORY_SEPARATOR)) { + return true; + } + } + + return $this->isPackageConfigFile($file); + } + /** * Determine whether the file is a package config file: one that a provider merges, or * one that a provider publishes into the config directory. diff --git a/src/phpstan/src/Support/ConstructorParser.php b/src/phpstan/src/Support/ConstructorParser.php index 2db4f4e89..8bc6c10f9 100644 --- a/src/phpstan/src/Support/ConstructorParser.php +++ b/src/phpstan/src/Support/ConstructorParser.php @@ -32,7 +32,7 @@ use function strtolower; /** - * Reads the constructor a class's instances run from its source. + * Reads a class's methods from its source, such as the constructor its instances run. */ #[AutowiredService] class ConstructorParser @@ -62,22 +62,45 @@ public function __construct( * @return null|array{ClassMethod, ClassReflection} */ public function findConstructor(ClassReflection $classReflection): ?array + { + $constructor = $this->findMethod($classReflection, '__construct'); + + if ($constructor === null) { + return null; + } + + $declaringClass = $classReflection->getNativeReflection()->getMethod('__construct')->getDeclaringClass()->getName(); + + return [ + $constructor[0], + $classReflection->getAncestorWithClassName($declaringClass) + ?? throw new LogicException(sprintf('Constructor declaring class [%s] is not an ancestor of [%s].', $declaringClass, $classReflection->getName())), + ]; + } + + /** + * Find the declaration of the method the class runs under the name (its own, an inherited one or a + * trait's), with the file declaring it. Null when the class has no such method in source. + * + * @return null|array{ClassMethod, string} + */ + public function findMethod(ClassReflection $classReflection, string $name): ?array { $native = $classReflection->getNativeReflection(); - if (! $native->hasMethod('__construct')) { + if (! $native->hasMethod($name)) { return null; } - $constructor = $native->getMethod('__construct'); - $fileName = $constructor->getFileName(); + $reflection = $native->getMethod($name); + $fileName = $reflection->getFileName(); if ($fileName === false) { return null; } - // A trait method imported as the constructor keeps its own name, so the node is found by its position. - $method = $constructor->getBetterReflection(); + // A trait method imported under another name keeps its own, so the node is found by its position. + $method = $reflection->getBetterReflection(); $source = $method->getLocatedSource()->getSource(); $node = (new NodeFinder)->findFirst( $this->parser->parseFile($fileName), @@ -87,16 +110,10 @@ public function findConstructor(ClassReflection $classReflection): ?array ); if (! $node instanceof ClassMethod) { - throw new LogicException(sprintf('Constructor of [%s] was not found in [%s].', $classReflection->getName(), $fileName)); + throw new LogicException(sprintf('Method [%s::%s] was not found in [%s].', $classReflection->getName(), $name, $fileName)); } - $declaringClass = $constructor->getDeclaringClass()->getName(); - - return [ - $node, - $classReflection->getAncestorWithClassName($declaringClass) - ?? throw new LogicException(sprintf('Constructor declaring class [%s] is not an ancestor of [%s].', $declaringClass, $classReflection->getName())), - ]; + return [$node, $fileName]; } /** diff --git a/src/phpstan/src/Support/FacadeHelper.php b/src/phpstan/src/Support/FacadeHelper.php new file mode 100644 index 000000000..93ea2bef7 --- /dev/null +++ b/src/phpstan/src/Support/FacadeHelper.php @@ -0,0 +1,401 @@ + + */ + private array $accessors = []; + + /** + * The root class of each facade, or null when it is unknown, with the classes whose declarations decided it. + * + * @var array}> + */ + private array $roots = []; + + /** + * Create a new facade helper instance. + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + #[AutowiredParameter(ref: '@cachedRichParser')] + private Parser $parser, + private RuntimeInputs $runtimeInputs, + ) { + } + + /** + * Get the container key the facade resolves, or null when its accessor does not return one constant. + * + * @param DependencyTracker&Scope $scope + */ + public function getAccessor(ClassReflection $facade, Scope $scope): ?string + { + $this->runtimeInputs->trackSources($scope, $facade->getName(), Facade::class); + + if (array_key_exists($facade->getName(), $this->accessors)) { + return $this->accessors[$facade->getName()]; + } + + return $this->accessors[$facade->getName()] = $this->findAccessor($facade); + } + + /** + * Get the exact class the facade's root is built as, or null when it is not known. + * + * @param DependencyTracker&Scope $scope + */ + public function getRootClass(ClassReflection $facade, Scope $scope): ?string + { + $accessor = $this->getAccessor($facade, $scope); + + $this->runtimeInputs->track($scope); + + [$root, $classes] = $this->roots[$facade->getName()] ??= $this->resolveRootClass($facade, $accessor); + + foreach ($classes as $class) { + $this->runtimeInputs->trackClass($scope, $class); + } + + return $root; + } + + /** + * Get the method a static call on the facade runs: the facade's own public method, else its + * root's, or null when the root or the facade's forwarding is unknown. + * + * @param DependencyTracker&Scope $scope + */ + public function getMethod(ClassReflection $facade, string $name, Scope $scope): ?ExtendedMethodReflection + { + if ($facade->hasNativeMethod($name) && $facade->getNativeMethod($name)->isPublic()) { + return $facade->getNativeMethod($name); + } + + $root = $this->getRootClass($facade, $scope); + + if ($root === null || ! self::forwardsToRoot($facade)) { + return null; + } + + $class = $this->reflectionProvider->getClass($root); + + // __callStatic() calls the root from outside, so only a public method is reachable. + return $class->hasNativeMethod($name) && $class->getNativeMethod($name)->isPublic() + ? $class->getNativeMethod($name) + : null; + } + + /** + * Determine whether the facade forwards static calls it doesn't declare to its root. Hash's and Crypt's + * __callStatic() forward as Facade's does, keeping the arguments out of stack traces; another can + * forward the call anywhere. + */ + public static function forwardsToRoot(ClassReflection $facade): bool + { + return in_array($facade->getNativeMethod('__callStatic')->getDeclaringClass()->getName(), [Facade::class, Hash::class, Crypt::class], true); + } + + /** + * Get the method the call runs on each class it may be made on. + * + * A static call on a facade runs the facade's own method or its root's. + * + * @param DependencyTracker&Scope $scope + * + * @return list + */ + public function getCalledMethods(MethodCall|StaticCall $call, string $name, Scope $scope): array + { + $classNames = match (true) { + ! $call instanceof StaticCall => $scope->getType($call->var)->getObjectClassNames(), + $call->class instanceof Name => [$scope->resolveName($call->class)], + default => $scope->getType($call->class)->getObjectTypeOrClassStringObjectType()->getObjectClassNames(), + }; + + $methods = []; + + foreach ($classNames as $className) { + if (! $this->reflectionProvider->hasClass($className)) { + continue; + } + + $class = $this->reflectionProvider->getClass($className); + + $method = match (true) { + $call instanceof StaticCall && $class->is(Facade::class) => $this->getMethod($class, $name, $scope), + $class->hasMethod($name) => $class->getMethod($name, $scope), + default => null, + }; + + if ($method !== null) { + $methods[] = $method; + } + } + + return $methods; + } + + /** + * Find the accessor the facade's nearest getFacadeAccessor() returns as a string or class constant. + */ + private function findAccessor(ClassReflection $facade): ?string + { + $method = $facade->getNativeReflection()->getMethod('getFacadeAccessor'); + + // The base accessor throws. + if ($method->getDeclaringClass()->getName() === Facade::class) { + return null; + } + + return $this->getLiteralString( + $this->getReturnedExpression($method->getFileName(), $method->getStartLine(), $method->getEndLine(), [ClassMethod::class]), + ); + } + + /** + * Resolve the class the facade's root is built as, with the classes whose declarations decided it. + * + * @return array{null|string, list} + */ + private function resolveRootClass(ClassReflection $facade, ?string $accessor): array + { + $resolver = $facade->getNativeMethod('resolveFacadeInstance')->getDeclaringClass()->getName(); + + if ($accessor === null + || ($resolver !== Facade::class && $resolver !== Date::class) + || $facade->getNativeMethod('getFacadeRoot')->getDeclaringClass()->getName() !== Facade::class) { + return [null, []]; + } + + // The analysis always runs on the framework container: the booted application or a test's own. + /** @var Container $container */ + $container = $this->getContainer(); + + // Date constructs its default factory directly while its accessor is unbound. The constant's + // value is read from its declaration, as a typed constant's value type is its declared type. + if ($resolver === Date::class && ! $container->bound($accessor)) { + $default = $this->getLiteralString($facade->getConstant('DEFAULT_FACADE')->getValueExpr()); + + return $default === null ? [null, []] : $this->getExactClass($default); + } + + [$instances, $autoSingletons, $extenders] = (fn (): array => [ + $this->instances, + $this->autoSingletons, + $this->extenders, + ])->call($container); + + $bindings = $container->getBindings(); + $abstract = $container->getAlias($accessor); + + // Only the facade's own make() returns an auto-singleton. A concrete reached through a + // binding is built again, as resolve() skips the implicit cache for it. + if (isset($autoSingletons[$abstract])) { + return $this->getExactClass($autoSingletons[$abstract]::class); + } + + $visited = []; + + while (! isset($visited[$abstract])) { + $visited[$abstract] = true; + + // extend() replaces a stored instance directly, so it is still the exact object. + if (array_key_exists($abstract, $instances)) { + return is_object($instances[$abstract]) ? $this->getExactClass($instances[$abstract]::class) : [null, []]; + } + + // resolve() passes what it builds through the extenders, which can return another object. + if (($extenders[$abstract] ?? []) !== []) { + return [null, []]; + } + + $binding = $bindings[$abstract] ?? null; + + if ($binding === null) { + return $this->getBuiltClass($abstract); + } + + $concrete = WorkerLifetimes::getBindingConcrete($binding['concrete']); + + if ($concrete === null) { + return $this->getFactoryClass($binding['concrete']); + } + + if ($concrete === $abstract) { + return $this->getBuiltClass($abstract); + } + + $abstract = $container->getAlias($concrete); + } + + // The container fails on a binding cycle. + return [null, []]; + } + + /** + * Get the class the container builds for the name itself, when it is buildable and not self-building. + * + * A self-building class is built through its newInstance(), which may return a subclass. + * + * @return array{null|string, list} + */ + private function getBuiltClass(string $name): array + { + if (! $this->reflectionProvider->hasClass($name)) { + return [null, []]; + } + + $class = $this->reflectionProvider->getClass($name); + + return WorkerLifetimes::isBuildableClass($class) && ! $class->is(SelfBuilding::class) + ? [$class->getName(), [$class->getName()]] + : [null, [$class->getName()]]; + } + + /** + * Get the class a factory closure constructs when its whole body is one `new` of a named class. + * + * @return array{null|string, list} + */ + private function getFactoryClass(Closure $factory): array + { + $reflection = new ReflectionFunction($factory); + + $returned = $this->getReturnedExpression( + $reflection->getFileName(), + $reflection->getStartLine(), + $reflection->getEndLine(), + [ClosureNode::class, ArrowFunction::class], + ); + + // The name resolver makes every named class fully qualified, leaving self, static and parent relative. + return $returned instanceof New_ && $returned->class instanceof FullyQualified + ? $this->getExactClass($returned->class->toString()) + : [null, []]; + } + + /** + * Get a class known to be constructed exactly, when the analysis knows it. + * + * @return array{null|string, list} + */ + private function getExactClass(string $name): array + { + if (! $this->reflectionProvider->hasClass($name)) { + return [null, []]; + } + + $name = $this->reflectionProvider->getClass($name)->getName(); + + return [$name, [$name]]; + } + + /** + * Get the string a string literal or `X::class` expression gives, or null for any other expression. + */ + private function getLiteralString(?Expr $expr): ?string + { + // The name resolver makes every named class fully qualified, leaving self, static and parent relative. + return match (true) { + $expr instanceof String_ => $expr->value, + $expr instanceof ClassConstFetch + && $expr->class instanceof FullyQualified + && $expr->name instanceof Identifier + && $expr->name->toLowerString() === 'class' => $expr->class->toString(), + default => null, + }; + } + + /** + * Get the expression a function returns when its body is a single return, finding the function in + * its file by its lines. + * + * Reflection gives lines but no columns, so the function is found only when exactly one of the + * given kinds spans those lines. + * + * @param list> $kinds + */ + private function getReturnedExpression(false|string $file, false|int $startLine, false|int $endLine, array $kinds): ?Expr + { + if ($file === false || $startLine === false || $endLine === false) { + return null; + } + + $functions = (new NodeFinder)->find( + $this->parser->parseFile($file), + static fn (Node $node): bool => $node->getStartLine() === $startLine + && $node->getEndLine() === $endLine + && array_any($kinds, static fn (string $kind): bool => $node instanceof $kind), + ); + + if (count($functions) !== 1 || ! $functions[0] instanceof FunctionLike) { + return null; + } + + $statements = $functions[0]->getStmts() ?? []; + + return count($statements) === 1 && $statements[0] instanceof Return_ ? $statements[0]->expr : null; + } +} diff --git a/src/phpstan/src/Support/RuntimeInputs.php b/src/phpstan/src/Support/RuntimeInputs.php index c7efdbd59..acc51142b 100644 --- a/src/phpstan/src/Support/RuntimeInputs.php +++ b/src/phpstan/src/Support/RuntimeInputs.php @@ -113,9 +113,9 @@ class RuntimeInputs private ?string $viewsKey = null; /** - * The owned runtime copy and the skeleton it was made from, or false when there is none. + * The owned runtime copy, the skeleton it was made from and the working vendor directory, or false when there is none. * - * @var null|array{string, string}|false + * @var null|array{string, string, string}|false */ private array|false|null $runtimeCopy = null; @@ -213,11 +213,12 @@ public function trackClass(Scope|ClassReflection $dependent, string $className): * Declare that the dependent depends on the declarations and code of a class the booted application selected and the analysis runs. * * Method bodies and property defaults are not declarations, so the files declaring the class and its - * parents below the base class, with their traits and the constants their defaults read, are tracked too. + * parents below the base class, or all of them without one, with their traits and the constants their + * defaults read, are tracked too. * * @param ClassReflection|(DependencyTracker&Scope) $dependent */ - public function trackSources(Scope|ClassReflection $dependent, string $className, string $baseClass): void + public function trackSources(Scope|ClassReflection $dependent, string $className, ?string $baseClass): void { $this->trackClass($dependent, $className); @@ -377,7 +378,23 @@ private function createKey(): string $scannedFiles[] = $file; } - foreach (array_keys([$vendorPath => true, $app->make(PackageManifest::class)->vendorPath => true]) as $vendor) { + $manifestVendorPath = $app->make(PackageManifest::class)->vendorPath; + $runtimeCopy = $this->runtimeCopy(); + $vendors = [$vendorPath => true]; + $presenceDirectories = []; + + // Testbench copies or links the skeleton's vendor directory into the runtime copy, or links + // the working one when the skeleton has none. The copy's own directory goes with the copy, + // so both sources are tracked in its place, including while their metadata is missing. + if ($runtimeCopy !== false && $manifestVendorPath === $runtimeCopy[0] . '/vendor') { + $presenceDirectories[] = $runtimeCopy[1] . '/vendor'; + $vendors[$runtimeCopy[1] . '/vendor'] = true; + $vendors[$runtimeCopy[2]] = true; + } else { + $vendors[$manifestVendorPath] = true; + } + + foreach (array_keys($vendors) as $vendor) { $files[] = $vendor . '/composer/installed.json'; $files[] = $vendor . '/composer/installed.php'; } @@ -422,6 +439,7 @@ private function createKey(): string return $this->valueExtension->createKey( files: $trackedFiles, presenceFiles: $this->presenceFiles($app), + presenceDirectories: $presenceDirectories, directories: $directories, variables: [...$variables['names'], ...$this->interpolatedVariables($app)], variablePatterns: $variables['patterns'], @@ -661,9 +679,10 @@ private function isInRuntimeCopy(string $path): bool } /** - * Get the realpaths of the runtime copy this process owns and of the skeleton it was made from. + * Get the realpaths of the runtime copy this process owns and of the skeleton it was made from, + * and the working vendor directory the copy links when the skeleton has none, as Testbench spells it. * - * @return array{string, string}|false + * @return array{string, string, string}|false */ private function runtimeCopy(): array|false { @@ -677,12 +696,13 @@ private function runtimeCopy(): array|false $basePath = $this->application()->basePath(); $sourcePath = Bootstrapper::runtimeSourcePath(); + $vendorPath = Bootstrapper::runtimeVendorPath(); - if ($sourcePath === null || ! Bootstrapper::ownsRuntimePath($basePath)) { + if ($sourcePath === null || $vendorPath === null || ! Bootstrapper::ownsRuntimePath($basePath)) { return $this->runtimeCopy = false; } - return $this->runtimeCopy = [(string) realpath($basePath), (string) realpath($sourcePath)]; + return $this->runtimeCopy = [(string) realpath($basePath), (string) realpath($sourcePath), $vendorPath]; } /** diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 78b6cd40a..151be8aa8 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -631,9 +631,7 @@ protected function getNextAvailableJob(?string $queue): ?DatabaseJobRecord */ protected function getLockForPopping(): bool|string { - $connection = $this->getDatabase(); - - return $connection instanceof Connection && $connection->supportsSkipLocked() + return $this->getDatabase()->supportsSkipLocked() ? 'FOR UPDATE SKIP LOCKED' : true; } @@ -818,7 +816,7 @@ protected function availableAtForOutbox(?string $queue, ?int $deliverAt): int /** * Get the underlying database connection. */ - public function getDatabase(): ConnectionInterface + public function getDatabase(): Connection { return $this->resolver->connection($this->connection); } diff --git a/src/queue/src/Outbox/DatabaseOutboxRepository.php b/src/queue/src/Outbox/DatabaseOutboxRepository.php index 5864b3dbf..a23588a3b 100644 --- a/src/queue/src/Outbox/DatabaseOutboxRepository.php +++ b/src/queue/src/Outbox/DatabaseOutboxRepository.php @@ -55,7 +55,7 @@ public function getDatabases(): array /** * Resolve a configured outbox source in the current coroutine. */ - public function getConnection(string $database): ConnectionInterface + public function getConnection(string $database): Connection { if (! in_array($database, $this->databases, true)) { throw new InvalidArgumentException("Outbox database [{$database}] is not configured in queue.outbox.databases."); @@ -85,7 +85,7 @@ public function claim(string $database, int $limit): array $now = $this->currentTime(); $token = (string) Str::uuid(); $expiresAt = $now + $this->retryAfter; - $skipLocked = $connection instanceof Connection && $connection->supportsSkipLocked(); + $skipLocked = $connection->supportsSkipLocked(); $limit = min($limit, $this->maxBindings($connection) - 3); if ($skipLocked && $connection->getDriverName() === 'pgsql') { diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php index 2699e82d6..dfdc1762d 100644 --- a/src/queue/src/QueuePoolProxy.php +++ b/src/queue/src/QueuePoolProxy.php @@ -246,7 +246,10 @@ public function publishOutboxMessages(array $messages): OutboxBatchResult /** * Run a callback with the underlying SQS queue. * - * @param Closure(SqsQueue): mixed $callback + * @template T + * + * @param Closure(SqsQueue): T $callback + * @return T */ public function withConnection(Closure $callback): mixed { diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 3ac0b5c3c..7e6d49b50 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -540,6 +540,10 @@ public function withConnection(callable $callback, bool $transform = true): mixe * All Redis operations inside the callback will reuse the same connection, * avoiding multiple pool checkouts. Re-entrant: if a connection is already * pinned, the callback runs on it without re-pinning. + * + * @template T + * @param callable(): T $callback + * @return T */ public function withPinnedConnection(callable $callback): mixed { diff --git a/src/sentry/config/sentry.php b/src/sentry/config/sentry.php index c4c8ab8f8..d562efca7 100644 --- a/src/sentry/config/sentry.php +++ b/src/sentry/config/sentry.php @@ -32,6 +32,7 @@ // Set to true to use the default Spotlight endpoint, a URL string to use // a custom endpoint, or false to disable Spotlight. // @see https://spotlightjs.com/ + // @phpstan-ignore hypervel.uncastEnvValue (true, false or a custom endpoint URL, which SentryConfig reads) 'spotlight' => env('SENTRY_SPOTLIGHT', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger diff --git a/src/support/README.md b/src/support/README.md index 01b4fdf1e..3152a7365 100644 --- a/src/support/README.md +++ b/src/support/README.md @@ -5,7 +5,7 @@ Support for Hypervel ## Differences From Laravel -- Facades cache non-scoped services for the worker lifetime, including `bind()` bindings. Use `scoped()` for request or job state, or disable facade caching for fresh resolutions. See [facade lifetimes](https://hypervel.org/docs/facades#how-facades-work). +- The framework binds `date` to `DateFactory`, so a `Date` facade subclass's `DEFAULT_FACADE` only applies while `date` is unbound. In an application, bind `date` to your factory instead. - `all([])` on `Fluent`, `ValidatedInput`, and `UriQueryString` returns an empty array. Use `all()` to retrieve every field. - Laravel's deferred service-provider metadata APIs are intentionally omitted. Hypervel registers providers once for a long-lived worker, so the per-request bootstrap optimization those APIs support does not apply. - Node package-manager detection walks from the current directory to the filesystem root, so applications inside a workspace use the nearest ancestor lockfile. Within each directory, detection prefers Bun, pnpm, Yarn, then npm. diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index 585ae150c..d1b613360 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -104,6 +104,7 @@ * @method static void registered(callable $callback) * @method static array resolveContextualParameters(string $concrete, null|array $names = null) * @method static bool resolvedScoped(string $abstract) + * @method static bool resolvedShared(string $abstract) * @method static void resolveEnvironmentUsing(null|callable $callback) * @method static mixed resolveFromAttribute(\ReflectionAttribute $attribute, \ReflectionParameter $parameter) * @method static \Hypervel\Support\ServiceProvider resolveProvider(string $provider) diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 0f173a126..9251901c6 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -15,7 +15,7 @@ * @method static void allowQueryDurationHandlersToRunAgain() * @method static string[] availableDrivers() * @method static never build(array $config) - * @method static \Hypervel\Database\ConnectionInterface connection(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Database\Connection connection(\UnitEnum|string|null $name = null) * @method static string connectionDateFormat(\UnitEnum|string|null $name = null) * @method static never connectUsing(string $name, array $config, bool $force = false) * @method static void disconnect(\UnitEnum|string|null $name = null) @@ -25,7 +25,7 @@ * @method static void forgetExtension(string $name) * @method static array getConnections() * @method static string|null getDefaultConnection() - * @method static \Hypervel\Database\ConnectionInterface|null getHeldConnection(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Database\Connection|null getHeldConnection(\UnitEnum|string|null $name = null) * @method static bool hasApplicableTransaction(\UnitEnum|string|null $name = null) * @method static bool hasMacro(string $name) * @method static void macro(string $name, callable|object $macro) diff --git a/src/support/src/Facades/Date.php b/src/support/src/Facades/Date.php index b7ad47a7a..3860f2240 100644 --- a/src/support/src/Facades/Date.php +++ b/src/support/src/Facades/Date.php @@ -126,9 +126,9 @@ protected static function getFacadeAccessor(): string protected static function resolveFacadeInstance(string $name): mixed { if (! isset(static::$resolvedInstance[$name]) && (static::$app === null || ! static::$app->bound($name))) { - $class = static::DEFAULT_FACADE; - - static::swap(new $class); + // Laravel swaps the default in. The framework binds `date`, so this only serves a + // missing or bare container, and caching the default keeps the test-only swap() out. + static::$resolvedInstance[$name] = new (static::DEFAULT_FACADE); } return parent::resolveFacadeInstance($name); diff --git a/src/support/src/Facades/Facade.php b/src/support/src/Facades/Facade.php index c93385b59..ac81711fc 100644 --- a/src/support/src/Facades/Facade.php +++ b/src/support/src/Facades/Facade.php @@ -5,6 +5,7 @@ namespace Hypervel\Support\Facades; use Closure; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Arr; @@ -30,6 +31,11 @@ abstract class Facade { + /** + * The context key for the coroutine-local facade roots. + */ + protected const string STATE_CONTEXT_KEY = '__support.facade_state'; + /** * The application instance being facaded. */ @@ -47,6 +53,13 @@ abstract class Facade */ protected static array $scopedAccessors = []; + /** + * Accessors with coroutine-local roots, and their current generation. + * + * @var array + */ + protected static array $coroutineAccessors = []; + /** * Indicates if the resolved instance should be cached. * @@ -54,9 +67,10 @@ abstract class Facade * instead of caching in $resolvedInstance. Useful for stateful services * like Pipeline where each usage needs a fresh instance. * - * Scoped bindings always resolve through the container, preserving the - * current coroutine's instance. When caching is enabled, other bindings - * keep their first resolved instance for the worker lifetime. + * When caching is enabled, the facade keeps an instance for as long as + * the container shares it: worker-shared instances for the worker lifetime, + * and other bindings for the current coroutine. Scoped bindings always + * resolve through the container, preserving the current coroutine's instance. * The container's lifetime still applies: disabling this cache does not * make singletons or auto-singletons fresh. */ @@ -255,32 +269,45 @@ protected static function resolveFacadeInstance(string $name): mixed return static::$resolvedInstance[$name]; } - if (static::$app) { - $instance = static::$app->make($name); + if (! static::$app) { + return null; + } - if (! static::$cached || isset(static::$scopedAccessors[$name]) || static::$app->isScoped($name)) { - return $instance; + if (static::$cached && isset(static::$coroutineAccessors[$name])) { + $root = CoroutineContext::get(self::STATE_CONTEXT_KEY)?->roots[$name] ?? null; + + if ($root !== null && $root[0] === static::$coroutineAccessors[$name]) { + return $root[1]; } + } + $instance = static::$app->make($name); + + if (! static::$cached || isset(static::$scopedAccessors[$name]) || static::$app->isScoped($name)) { + return $instance; + } + + if (static::$app->resolvedShared($name)) { + static::$resolvedInstance[$name] = $instance; + } elseif (static::$app->isScopedInstance($instance)) { // A transient factory can return a scoped service. Remember its lifetime // so later calls neither retain the object nor repeat the scoped-store scan. - if (static::$app->isScopedInstance($instance)) { - static::$scopedAccessors[$name] = true; - } else { - static::$resolvedInstance[$name] = $instance; - } + static::$scopedAccessors[$name] = true; + } else { + $state = CoroutineContext::get(self::STATE_CONTEXT_KEY) + ?? CoroutineContext::set(self::STATE_CONTEXT_KEY, new FacadeState); - return $instance; + $state->roots[$name] = [static::$coroutineAccessors[$name] ??= 0, $instance]; } - return null; + return $instance; } /** * Clear a resolved facade instance. * - * Clears a worker-wide facade cache entry; concurrent coroutines may - * still hold the previous resolved instance. + * Later facade calls everywhere resolve again; only references + * already held keep the previous instance. */ #[BootOrTestsOnly] public static function clearResolvedInstance(?string $name = null): void @@ -288,19 +315,27 @@ public static function clearResolvedInstance(?string $name = null): void $name ??= static::getFacadeAccessor(); unset(static::$resolvedInstance[$name], static::$scopedAccessors[$name]); + + if (isset(static::$coroutineAccessors[$name])) { + ++static::$coroutineAccessors[$name]; + } } /** * Clear all of the resolved instances. * - * Clears the worker-wide facade cache; concurrent coroutines may - * still hold previously resolved instances. + * Later facade calls everywhere resolve again; only references + * already held keep the previous instances. */ #[BootOrTestsOnly] public static function clearResolvedInstances(): void { static::$resolvedInstance = []; static::$scopedAccessors = []; + + foreach (static::$coroutineAccessors as $name => $generation) { + static::$coroutineAccessors[$name] = $generation + 1; + } } /** diff --git a/src/support/src/Facades/FacadeState.php b/src/support/src/Facades/FacadeState.php new file mode 100644 index 000000000..0e3c06e4c --- /dev/null +++ b/src/support/src/Facades/FacadeState.php @@ -0,0 +1,17 @@ + + */ + public array $roots = []; +} diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 5e003c052..470a25ea7 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -37,6 +37,11 @@ class Bootstrapper */ protected static ?string $runtimeSourcePath = null; + /** + * The working package's vendor directory, which the runtime copy links when its skeleton has none. + */ + protected static ?string $runtimeVendorPath = null; + /** * Bootstrap the testbench environment. * @@ -127,6 +132,15 @@ public static function runtimeSourcePath(): ?string return static::$runtimeSourcePath; } + /** + * Get the working package's vendor directory, which the runtime copy created by this process links + * when its skeleton has none. + */ + public static function runtimeVendorPath(): ?string + { + return static::$runtimeVendorPath; + } + /** * Determine if the given path is a disposable runtime copy. * @@ -270,6 +284,7 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP $runtimePath = $tempDir . "/hypervel-components-testbench-{$token}-{$pid}"; $filesystem = static::getFilesystem(); + $vendorPath = Composer::vendorPath($workingPath); static::purgeStaleRuntimeCopies($tempDir, $pid); @@ -286,7 +301,7 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP throw new RuntimeException("Unable to create runtime path [{$runtimePath}]."); } - if (! static::copySkeleton($filesystem, $sourcePath, $runtimePath, Composer::vendorPath($workingPath))) { + if (! static::copySkeleton($filesystem, $sourcePath, $runtimePath, $vendorPath)) { throw new RuntimeException("Unable to create the Testbench runtime copy at [{$runtimePath}]."); } @@ -320,6 +335,7 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP static::$runtimePath = $runtimePath; static::$runtimeSourcePath = $sourcePath; + static::$runtimeVendorPath = $vendorPath; register_shutdown_function(static function () use ($pid): void { // A forked child inherits this callback but owns no runtime copy. @@ -499,6 +515,7 @@ protected static function deleteRuntimeCopy(): void static::$runtimePath = null; static::$runtimeSourcePath = null; + static::$runtimeVendorPath = null; } /** diff --git a/src/watcher/src/Watcher.php b/src/watcher/src/Watcher.php index c04785333..806423337 100644 --- a/src/watcher/src/Watcher.php +++ b/src/watcher/src/Watcher.php @@ -44,6 +44,7 @@ public function run(): void try { $this->strategy?->start(); + // @phpstan-ignore hypervel.rawCoroutine (the callback catches every Throwable, including cancellation, and the command rethrows it) Coroutine::create(function () use ($channel, $driverFinished, &$driverFailure, &$driverStarted): void { $driverStarted = true; diff --git a/tests/Auth/AuthDatabaseTokenRepositoryTest.php b/tests/Auth/AuthDatabaseTokenRepositoryTest.php index 694bfe409..e24fa604d 100755 --- a/tests/Auth/AuthDatabaseTokenRepositoryTest.php +++ b/tests/Auth/AuthDatabaseTokenRepositoryTest.php @@ -7,6 +7,7 @@ use Hypervel\Auth\Passwords\DatabaseTokenRepository; use Hypervel\Contracts\Auth\CanResetPassword; use Hypervel\Contracts\Hashing\Hasher; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Query\Builder; @@ -126,13 +127,13 @@ public function testDeleteExpiredMethodDeletesExpiredTokens(): void public function testResolverBackedRepositoryResolvesTheConfiguredConnectionForEveryOperation(): void { - $firstConnection = m::mock(ConnectionInterface::class); + $firstConnection = m::mock(Connection::class); $firstQuery = m::mock(Builder::class); $firstConnection->shouldReceive('table')->once()->with('table')->andReturn($firstQuery); $firstQuery->shouldReceive('where')->once()->with('created_at', '<', m::any())->andReturnSelf(); $firstQuery->shouldReceive('delete')->once(); - $secondConnection = m::mock(ConnectionInterface::class); + $secondConnection = m::mock(Connection::class); $secondQuery = m::mock(Builder::class); $secondConnection->shouldReceive('table')->once()->with('table')->andReturn($secondQuery); $secondQuery->shouldReceive('where')->once()->with('created_at', '<', m::any())->andReturnSelf(); @@ -155,7 +156,7 @@ public function testResolverBackedRepositoryResolvesTheConfiguredConnectionForEv public function testResolverBackedRepositoryUsesTheDefaultConnectionWhenNoNameIsConfigured(): void { - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $resolver = m::mock(ConnectionResolverInterface::class); $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection); @@ -166,7 +167,7 @@ public function testResolverBackedRepositoryUsesTheDefaultConnectionWhenNoNameIs public function testResolverContractTakesPrecedenceForObjectsImplementingBothConnectionContracts(): void { - $resolvedConnection = m::mock(ConnectionInterface::class); + $resolvedConnection = m::mock(Connection::class); $connectionResolver = m::mock(ConnectionInterface::class . ', ' . ConnectionResolverInterface::class); $connectionResolver->shouldReceive('connection')->once()->with('passwords')->andReturn($resolvedConnection); diff --git a/tests/Auth/AuthDatabaseUserProviderTest.php b/tests/Auth/AuthDatabaseUserProviderTest.php index 5d6e758b0..3e5824413 100755 --- a/tests/Auth/AuthDatabaseUserProviderTest.php +++ b/tests/Auth/AuthDatabaseUserProviderTest.php @@ -8,6 +8,7 @@ use Hypervel\Auth\GenericUser; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Hashing\Hasher; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\PdoConnection; @@ -42,12 +43,12 @@ public function testRetrieveByIDReturnsNullWhenUserIsNotFound(): void public function testResolverBackedProviderResolvesTheConfiguredConnectionForEveryOperation(): void { - $firstConnection = m::mock(ConnectionInterface::class); + $firstConnection = m::mock(Connection::class); $firstQuery = m::mock(Builder::class); $firstConnection->shouldReceive('table')->once()->with('foo')->andReturn($firstQuery); $firstQuery->shouldReceive('find')->once()->with(1)->andReturn(['id' => 1, 'name' => 'First']); - $secondConnection = m::mock(ConnectionInterface::class); + $secondConnection = m::mock(Connection::class); $secondQuery = m::mock(Builder::class); $secondConnection->shouldReceive('table')->once()->with('foo')->andReturn($secondQuery); $secondQuery->shouldReceive('find')->once()->with(2)->andReturn(['id' => 2, 'name' => 'Second']); @@ -63,12 +64,12 @@ public function testResolverBackedProviderResolvesTheConfiguredConnectionForEver public function testResolverBackedProviderDoesNotRetainConnectionsAcrossExecutions(): void { - $firstConnection = m::mock(ConnectionInterface::class); + $firstConnection = m::mock(Connection::class); $firstQuery = m::mock(Builder::class); $firstConnection->shouldReceive('table')->once()->with('foo')->andReturn($firstQuery); $firstQuery->shouldReceive('find')->once()->andReturn(['id' => 1, 'name' => 'First']); - $secondConnection = m::mock(ConnectionInterface::class); + $secondConnection = m::mock(Connection::class); $secondQuery = m::mock(Builder::class); $secondConnection->shouldReceive('table')->once()->with('foo')->andReturn($secondQuery); $secondQuery->shouldReceive('find')->once()->andReturn(['id' => 2, 'name' => 'Second']); @@ -88,7 +89,7 @@ public function testResolverBackedProviderDoesNotRetainConnectionsAcrossExecutio public function testResolverBackedProviderUsesTheDefaultConnectionWhenNoNameIsConfigured(): void { - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $query = m::mock(Builder::class); $connection->shouldReceive('table')->once()->with('foo')->andReturn($query); $query->shouldReceive('find')->once()->with(1)->andReturn(null); @@ -103,7 +104,7 @@ public function testResolverBackedProviderUsesTheDefaultConnectionWhenNoNameIsCo public function testResolverContractTakesPrecedenceForObjectsImplementingBothConnectionContracts(): void { - $resolvedConnection = m::mock(ConnectionInterface::class); + $resolvedConnection = m::mock(Connection::class); $query = m::mock(Builder::class); $resolvedConnection->shouldReceive('table')->once()->with('foo')->andReturn($query); $query->shouldReceive('find')->once()->with(1)->andReturn(null); diff --git a/tests/Auth/AuthManagerTest.php b/tests/Auth/AuthManagerTest.php index 511c75419..7aa161022 100644 --- a/tests/Auth/AuthManagerTest.php +++ b/tests/Auth/AuthManagerTest.php @@ -30,7 +30,7 @@ use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Hashing\Hasher as HashContract; use Hypervel\Coroutine\Coroutine; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Query\Builder; @@ -370,7 +370,7 @@ public function testCreateDatabaseUserProvider(): void $query = m::mock(Builder::class); $query->shouldReceive('find')->once()->with(1)->andReturn(null); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('table')->once()->with('users')->andReturn($query); $resolutions = 0; $database = m::mock(ConnectionResolverInterface::class); @@ -402,7 +402,7 @@ public function testDatabaseUserProviderUsesDefaultConnectionWhenOmitted(): void ]); $query = m::mock(Builder::class); $query->shouldReceive('find')->once()->with(1)->andReturn(null); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('table')->once()->with('users')->andReturn($query); $database = m::mock(ConnectionResolverInterface::class); $database->shouldReceive('connection') diff --git a/tests/Auth/AuthPasswordBrokerManagerTest.php b/tests/Auth/AuthPasswordBrokerManagerTest.php index 177e0456f..0e54d4931 100644 --- a/tests/Auth/AuthPasswordBrokerManagerTest.php +++ b/tests/Auth/AuthPasswordBrokerManagerTest.php @@ -16,7 +16,7 @@ use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Hashing\Hasher; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Tests\TestCase; use InvalidArgumentException; @@ -315,7 +315,7 @@ public function testBrokerWithExplicitNameBypassesDefaultResolution(): void ->once() ->with('admins') ->andReturn(m::mock(UserProvider::class)); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $resolutions = 0; $database->shouldReceive('connection') ->once() @@ -392,7 +392,7 @@ public function testShippedDatabaseBrokerUsesDefaultConnection(): void ->once() ->with('users') ->andReturn(m::mock(UserProvider::class)); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $database->shouldReceive('connection')->once()->with(null)->andReturn($connection); $broker = (new PasswordBrokerManager($container))->broker('users'); diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index 8f6ddad25..6a34276ff 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -8,7 +8,7 @@ use Exception; use Hypervel\Cache\DatabaseLock; use Hypervel\Contracts\Cache\RefreshableLock; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Query\Builder; use Hypervel\Database\QueryException; @@ -484,7 +484,7 @@ public function testGetConnectionNameCanReturnNull(): void protected function getLock(int $seconds = 10, ?array $lockLottery = [0, 1], ?string $connectionName = 'default', string $driver = 'pgsql', array $tables = []): array { $resolver = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $table = m::mock(Builder::class); $table->shouldReceive('useWritePdo')->andReturnSelf()->byDefault(); diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index 570cd9f5e..3f4288c09 100644 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -8,7 +8,7 @@ use Closure; use Hypervel\Cache\DatabaseStore; use Hypervel\Cache\SerializableClassPolicy; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\PostgresConnection; use Hypervel\Database\Query\Builder; @@ -332,7 +332,7 @@ public function testItemsMayBeFlushedFromCache(): void public function testLocksMayBeFlushedFromCache(): void { [$store, , , $resolver] = $this->getStore(); - $lockConnection = m::mock(ConnectionInterface::class); + $lockConnection = m::mock(Connection::class); $lockTable = m::mock(Builder::class); $resolver->shouldReceive('connection')->with('locks')->andReturn($lockConnection); $lockConnection->expects('table')->with('cache_locks')->andReturn($lockTable); @@ -514,7 +514,7 @@ protected function getStore( ?SerializableClassPolicy $serializableClassPolicy = null, ): array { $resolver = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $table = m::mock(Builder::class); $resolver->shouldReceive('connection')->with('default')->andReturn($connection); @@ -572,7 +572,7 @@ protected function getSqliteStore(): array protected function getMocks(): array { $resolver = m::mock(ConnectionResolverInterface::class); - $resolver->shouldReceive('connection')->with('default')->andReturn(m::mock(ConnectionInterface::class)); + $resolver->shouldReceive('connection')->with('default')->andReturn(m::mock(Connection::class)); return [$resolver, 'default', 'table', 'prefix']; } diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index d66287800..0d000eb4d 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -424,6 +424,7 @@ public function testNullInstanceIsAResolvedSharedBinding(): void $this->assertTrue($container->has('nullable')); $this->assertTrue($container->resolved('nullable')); $this->assertTrue($container->isShared('nullable')); + $this->assertTrue($container->resolvedShared('nullable')); $this->assertNull($container->make('nullable')); $this->assertNull($container->get('nullable')); } @@ -819,6 +820,52 @@ public function testScopedInstancesAreIsolatedBetweenContainers(): void $this->assertNotSame($instance, $second->make(ContainerConcreteStub::class)); } + public function testResolvedSharedReportsWorkerSharedInstances(): void + { + $container = new Container; + $container->singleton('singleton', fn (): stdClass => new stdClass); + $container->alias(ContainerConcreteStub::class, 'stub'); + + $this->assertFalse($container->resolvedShared('singleton')); + $this->assertFalse($container->resolvedShared(ContainerConcreteStub::class)); + + $container->make('singleton'); + $container->make(ContainerConcreteStub::class); + + $this->assertTrue($container->resolvedShared('singleton')); + $this->assertTrue($container->resolvedShared(ContainerConcreteStub::class)); + $this->assertTrue($container->resolvedShared('stub')); + } + + public function testResolvedSharedIsFalseForResolutionsTheWorkerDoesNotKeep(): void + { + $container = new Container; + $container->bind('transient', fn (): stdClass => new stdClass); + $container->scoped('scoped', fn (): stdClass => new stdClass); + $container->singleton('shared', fn (): stdClass => new stdClass); + $container->bind('selector', fn (Container $container): stdClass => $container->make('shared')); + + $container->make('transient'); + $container->make('scoped'); + $container->make(TransientStub::class); + $container->make('selector'); + + $this->assertFalse($container->resolvedShared('transient')); + $this->assertFalse($container->resolvedShared('scoped')); + $this->assertFalse($container->resolvedShared(TransientStub::class)); + $this->assertFalse($container->resolvedShared('selector')); + $this->assertTrue($container->resolvedShared('shared')); + } + + public function testResolvedSharedSeesAnInstanceReplacingAScopedBinding(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + $container->instance(ContainerConcreteStub::class, new ContainerConcreteStub); + + $this->assertTrue($container->resolvedShared(ContainerConcreteStub::class)); + } + /** * @param Closure(Container): void $invalidate */ diff --git a/tests/Database/DatabaseMigratorTest.php b/tests/Database/DatabaseMigratorTest.php index 82b4dcae4..d81456742 100644 --- a/tests/Database/DatabaseMigratorTest.php +++ b/tests/Database/DatabaseMigratorTest.php @@ -8,7 +8,6 @@ use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Database\Connection; -use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolver; use Hypervel\Database\ConnectionResolverInterface as Resolver; use Hypervel\Database\Migrations\MigrationRepositoryInterface; @@ -812,7 +811,7 @@ class DatabaseMigratorTestResolver implements Resolver /** * Get a database connection instance. */ - public function connection(UnitEnum|string|null $name = null): ConnectionInterface + public function connection(UnitEnum|string|null $name = null): Connection { return $this->connections[$name ?? $this->default]; } diff --git a/tests/Database/DatabaseMonitorCommandTest.php b/tests/Database/DatabaseMonitorCommandTest.php index 3b87f6804..31ba0176c 100644 --- a/tests/Database/DatabaseMonitorCommandTest.php +++ b/tests/Database/DatabaseMonitorCommandTest.php @@ -6,7 +6,7 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Console\MonitorCommand; use Hypervel\Database\Events\DatabaseBusy; @@ -35,7 +35,7 @@ public function testMonitorCommandFailsWhenDatabaseDefaultIsMissing(): void public function testMonitorCommandPreservesConnectionNameZero(): void { - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('threadCount')->once()->andReturn(1); $resolver = m::mock(ConnectionResolverInterface::class); @@ -49,7 +49,7 @@ public function testMonitorCommandPreservesConnectionNameZero(): void public function testBusyEventIsNotDispatchedWithoutListeners(): void { - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('threadCount')->once()->andReturn(2); $resolver = m::mock(ConnectionResolverInterface::class); $resolver->shouldReceive('connection')->once()->with('primary')->andReturn($connection); @@ -67,7 +67,7 @@ public function testBusyEventIsNotDispatchedWithoutListeners(): void public function testBusyEventIsDispatchedWithAListener(): void { - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('threadCount')->once()->andReturn(2); $resolver = m::mock(ConnectionResolverInterface::class); $resolver->shouldReceive('connection')->once()->with('primary')->andReturn($connection); diff --git a/tests/Engine/SocketTest.php b/tests/Engine/SocketTest.php index 94856c316..1d2d11f42 100644 --- a/tests/Engine/SocketTest.php +++ b/tests/Engine/SocketTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Engine; +use Hypervel\Container\Container; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Engine\SocketInterface; use Hypervel\Engine\Channel; use Hypervel\Engine\Exceptions\SocketClosedException; @@ -11,7 +13,9 @@ use Hypervel\Engine\SafeSocket; use Hypervel\Engine\Socket; use Hypervel\Tests\TestCase; +use Mockery as m; use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use Psr\Log\LoggerInterface; use ReflectionProperty; use RuntimeException; use Swoole\Coroutine as SwooleCoroutine; @@ -363,6 +367,42 @@ function (int $port) use ($peerClosed): void { ); } + public function testSafeSocketClosesAndReportsWhenItsLoggerFails(): void + { + $sendFailure = new RuntimeException('Send failed.'); + $loggingFailure = new RuntimeException('Logging failed.'); + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->once()->with($loggingFailure); + $container = new Container; + $container->instance(ExceptionHandler::class, $handler); + Container::setInstance($container); + $logger = m::mock(LoggerInterface::class); + $logger->shouldReceive('critical')->once()->with((string) $sendFailure)->andThrow($loggingFailure); + $nativeSocket = new FailingSendSocket($sendFailure); + $socket = new SafeSocket($nativeSocket, logger: $logger); + /** @var Channel $channel */ + $channel = (new ReflectionProperty($socket, 'channel'))->getValue($socket); + $loop = new ReflectionProperty($socket, 'loop'); + + try { + $socket->sendAll('payload'); + + $deadline = hrtime(true) + 500_000_000; + + while ($loop->getValue($socket) === true && hrtime(true) < $deadline) { + usleep(100); + } + + $this->assertFalse($loop->getValue($socket)); + $this->assertTrue($channel->isClosing()); + + // Closing an already closed native socket fails. + $this->assertFalse($nativeSocket->close()); + } finally { + $nativeSocket->close(); + } + } + public function testSafeSocketPreservesAZeroStringPayload(): void { $nativeSocket = new ZeroPayloadSocket; @@ -508,6 +548,19 @@ private function driveSafeSocketToTerminalSendFailure(SafeSocket $socket): void } } +class FailingSendSocket extends SwooleSocket +{ + public function __construct(private Throwable $failure) + { + parent::__construct(AF_INET, SOCK_STREAM, 0); + } + + public function sendAll(string $data, float $timeout = 0): false|int + { + throw $this->failure; + } +} + class ZeroPayloadSocket extends SwooleSocket { public function __construct() diff --git a/tests/Foundation/Providers/FoundationServiceProviderTest.php b/tests/Foundation/Providers/FoundationServiceProviderTest.php index 2e621d228..a2d3c7009 100644 --- a/tests/Foundation/Providers/FoundationServiceProviderTest.php +++ b/tests/Foundation/Providers/FoundationServiceProviderTest.php @@ -18,6 +18,7 @@ use Hypervel\Http\Request; use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\DateFactory; use Hypervel\Support\Facades\Date; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; @@ -130,6 +131,15 @@ public function testClockReturnsCarbonImmutableWhenDateFacadeUsesMutableCarbon() ); } + public function testDateFactorySingletonIsRegistered(): void + { + $date = $this->app->make('date'); + + $this->assertSame(DateFactory::class, $date::class); + $this->assertSame($date, $this->app->make('date')); + $this->assertSame($date, Date::getFacadeRoot()); + } + public function testMaintenanceModeRefreshIntervalIsConfigured(): void { $this->assertSame(5, $this->app->make('config')->integer('app.maintenance.refresh_interval')); diff --git a/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php index 6aeb9d472..ca4b20edb 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithParallelDatabaseTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; use Closure; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\DatabaseManager; use Hypervel\Database\QueryException; use Hypervel\Database\Schema\Builder as SchemaBuilder; @@ -399,7 +399,7 @@ public function testEnsureParallelDatabaseExistsRecoversFromTheCurrentSuffixedDa ->once() ->with('analytics_test_data_test_7') ->andReturn(true); - $databaseConnection = m::mock(ConnectionInterface::class); + $databaseConnection = m::mock(Connection::class); $databaseConnection->shouldReceive('getSchemaBuilder') ->twice() ->andReturn($schemaBuilder); @@ -413,7 +413,7 @@ public function testEnsureParallelDatabaseExistsRecoversFromTheCurrentSuffixedDa $config, $configurationPath, $databaseConnection - ): ConnectionInterface { + ): Connection { ++$connectionCalls; if ($connectionCalls === 2) { diff --git a/tests/Foundation/Testing/DatabaseTransactionsTest.php b/tests/Foundation/Testing/DatabaseTransactionsTest.php index 0b41a680f..0bc518614 100644 --- a/tests/Foundation/Testing/DatabaseTransactionsTest.php +++ b/tests/Foundation/Testing/DatabaseTransactionsTest.php @@ -7,7 +7,6 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection; -use Hypervel\Database\ConnectionInterface; use Hypervel\Database\DatabaseManager; use Hypervel\Database\SQLiteConnection; use Hypervel\Foundation\Testing\DatabaseTransactions; @@ -22,7 +21,7 @@ public function testBeginTransactionRestoresTheDispatcherWhenItFails(): void { $failure = new RuntimeException('Transaction begin failed.'); $dispatcher = m::mock(Dispatcher::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('setTransactionManager')->once(); $connection->shouldReceive('getEventDispatcher')->once()->andReturn($dispatcher); $connection->shouldReceive('unsetEventDispatcher')->once()->ordered(); @@ -77,7 +76,7 @@ public function testRollbackRestoresTheDispatcherWhenItFails(): void /** * Create a test case for the given connection. */ - private function createTestCase(ConnectionInterface $connection): DatabaseTransactionsTestCase + private function createTestCase(Connection $connection): DatabaseTransactionsTestCase { $database = m::mock(DatabaseManager::class); $database->shouldReceive('connection')->once()->with(null)->andReturn($connection); diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index ee86e81e5..b9a95a305 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -7,7 +7,7 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\DatabaseManager; use Hypervel\Database\PdoConnection; use Hypervel\Foundation\Application; @@ -457,7 +457,7 @@ public function testRefreshTestDatabaseRequiresPdoForInMemoryConnection(): void $this->migrateRefresh = false; $this->runTestsInCoroutine = true; - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $database = m::mock(DatabaseManager::class); $database->expects('connection')->with(null)->andReturn($connection); @@ -489,7 +489,7 @@ public function testRefreshTestDatabaseRequiresPdoForInMemoryConnection(): void public function testRestoreInMemoryDatabaseRequiresPdoConnection(): void { $pdo = m::mock(PDO::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $database = m::mock(DatabaseManager::class); $database->expects('connection')->with(null)->andReturn($connection); RefreshDatabaseState::$inMemoryConnections = ['default' => $pdo]; @@ -617,7 +617,7 @@ public function testBeginTransactionRestoresTheDispatcherWhenItFails(): void { $failure = new RuntimeException('Transaction begin failed.'); $dispatcher = m::mock(Dispatcher::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->expects('setTransactionManager'); $connection->expects('getEventDispatcher')->andReturn($dispatcher); $connection->expects('unsetEventDispatcher')->ordered(); @@ -668,7 +668,7 @@ public function testRollbackRestoresTheDispatcherAndKeepsMigrationBookkeepingWhe */ protected function getMockedDatabase(): DatabaseManager { - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->expects('getNameWithReadWriteType')->andReturn('default'); $eventDispatcher = m::mock(Dispatcher::class); $connection->expects('getEventDispatcher') @@ -679,6 +679,7 @@ protected function getMockedDatabase(): DatabaseManager $connection->expects('beginTransaction')->andReturnUsing( fn () => $this->app->make('db.transactions')->begin('default', 1) ); + $connection->expects('forgetRecordModificationState'); $connection->expects('rollback'); $connection->expects('setEventDispatcher') ->twice() diff --git a/tests/Grpc/ConnectionTest.php b/tests/Grpc/ConnectionTest.php index 7c2331583..5b62fc246 100644 --- a/tests/Grpc/ConnectionTest.php +++ b/tests/Grpc/ConnectionTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Grpc; use Closure; +use Hypervel\Container\Container; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Engine\Http\V2\ClientFactoryInterface; use Hypervel\Contracts\Engine\Http\V2\ClientInterface; use Hypervel\Contracts\Engine\Http\V2\RequestInterface; @@ -28,6 +30,7 @@ use Hypervel\Grpc\StatusCode; use Hypervel\Tests\TestCase; use LogicException; +use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; use RuntimeException; @@ -545,10 +548,23 @@ static function () use (&$now): int { $this->assertSame(1, $client->closeCount); } - public function testNativeWriteCancellationTerminatesTheConnectionAndPreservesTheCaller(): void + #[TestWith([false])] + #[TestWith([true])] + public function testNativeWriteCancellationTerminatesTheConnectionAndPreservesTheCaller(bool $closeFails): void { $cancellation = new CanceledException; $client = new ConnectionTestClient; + + // A close failure carrying its own cancellation must not replace the caller's. + $closeFailure = $closeFails ? new RuntimeException('Close failed.', previous: new CanceledException) : null; + $this->expectReported($closeFailure); + + if ($closeFailure !== null) { + $client->closeUsing(static function () use ($closeFailure): void { + throw $closeFailure; + }); + } + $connection = $this->connection(new ConnectionTestClientFactory($client)); $state = $this->state(); $this->start($connection, $this->request(pipeline: true), $state); @@ -997,6 +1013,35 @@ public function testReceiveFailureFansOutWithoutImplicitReplay(): void $this->assertTrue($connection->isClosed()); } + public function testReceiverReportsANativeCloseFailureCarryingCancellation(): void + { + $closeFailure = new RuntimeException('Close failed.', previous: new CanceledException); + $this->expectReported($closeFailure); + $client = new ConnectionTestClient; + $client->closeUsing(static function () use ($closeFailure): void { + throw $closeFailure; + }); + $connection = $this->connection(new ConnectionTestClientFactory($client)); + $state = $this->state(); + $this->start($connection, $this->request(), $state); + $receiverCoroutineId = (new ReflectionProperty(Connection::class, 'receiverCoroutineId')) + ->getValue($connection); + + $client->failReceive(new HttpClientException('socket lost', 104)); + + $this->assertIsInt($receiverCoroutineId); + $this->assertFalse(Coroutine::exists($receiverCoroutineId)); + $this->assertTrue($connection->isClosed()); + $this->assertSame(1, $client->closeCount); + + try { + $state->status(); + $this->fail('Expected the receive failure to fail the call.'); + } catch (ConnectionException $exception) { + $this->assertSame('socket lost', $exception->getMessage()); + } + } + public function testWriteFailureFailsEveryStreamAndIsReportedToTheWriter(): void { $client = new ConnectionTestClient; @@ -1123,6 +1168,24 @@ private function connection( return $connection; } + /** + * Expect the failure to be reported through the exception handler, or nothing when it is null. + */ + private function expectReported(?Throwable $failure): void + { + $handler = m::mock(ExceptionHandler::class); + + if ($failure === null) { + $handler->shouldNotReceive('report'); + } else { + $handler->shouldReceive('report')->once()->with($failure); + } + + $container = new Container; + $container->instance(ExceptionHandler::class, $handler); + Container::setInstance($container); + } + private function state( ?Deadline $deadline = null, int $maxBufferedMessages = 128, diff --git a/tests/PHPStan/AttributeServicesResultCacheMetaExtensionTest.php b/tests/PHPStan/AttributeServicesResultCacheMetaExtensionTest.php index fac07ecb4..50e1845f5 100644 --- a/tests/PHPStan/AttributeServicesResultCacheMetaExtensionTest.php +++ b/tests/PHPStan/AttributeServicesResultCacheMetaExtensionTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\PHPStan; +use PHPUnit\Framework\Attributes\Large; + use function copy; use function mkdir; +#[Large] class AttributeServicesResultCacheMetaExtensionTest extends ResultCacheTestCase { protected string $fixture = 'AttributeServices'; diff --git a/tests/PHPStan/ConfigCompositionTest.php b/tests/PHPStan/ConfigCompositionTest.php index bb0284b28..5f296c709 100644 --- a/tests/PHPStan/ConfigCompositionTest.php +++ b/tests/PHPStan/ConfigCompositionTest.php @@ -5,11 +5,13 @@ namespace Hypervel\Tests\PHPStan; use Hypervel\Filesystem\Filesystem; +use PHPUnit\Framework\Attributes\Large; /** * The in-process suite boots a package, whose analysis leaves the values packages merge * unknown, so an application's own analysis checks how merged package files compose. */ +#[Large] class ConfigCompositionTest extends ResultCacheTestCase { protected string $fixture = 'ConfigComposition'; diff --git a/tests/PHPStan/Fixtures/ConfigComposition/application/app/ProviderSources.php b/tests/PHPStan/Fixtures/ConfigComposition/application/app/ProviderSources.php index bd21ca5f1..83ed0b5e7 100644 --- a/tests/PHPStan/Fixtures/ConfigComposition/application/app/ProviderSources.php +++ b/tests/PHPStan/Fixtures/ConfigComposition/application/app/ProviderSources.php @@ -4,12 +4,11 @@ namespace ConfigComposition; -use Hypervel\Contracts\Config\Repository; use Hypervel\Support\Facades\Config; use function PHPStan\Testing\assertType; -function test(Repository $config): void +function test(): void { // Definite merges, including keys resolved from a class constant and a static property. assertType("'package'", config('package.key')); @@ -62,6 +61,6 @@ function test(Repository $config): void assertType('mixed', config('merged.disks.local')); assertType('mixed', config('partial.other')); - assertType("'package'", $config->string('package.key')); + assertType("'package'", config()->string('package.key')); assertType("'app'", Config::get('merged.shared')); } diff --git a/tests/PHPStan/Fixtures/RuntimeInputs/Package/FrenchGreeterServiceProvider.php b/tests/PHPStan/Fixtures/RuntimeInputs/Package/FrenchGreeterServiceProvider.php new file mode 100644 index 000000000..b810282d8 --- /dev/null +++ b/tests/PHPStan/Fixtures/RuntimeInputs/Package/FrenchGreeterServiceProvider.php @@ -0,0 +1,18 @@ +app->bind(Greeter::class, FrenchGreeter::class); + } +} diff --git a/tests/PHPStan/Fixtures/SourceFiles/MarkedMethod.php b/tests/PHPStan/Fixtures/SourceFiles/MarkedMethod.php new file mode 100644 index 000000000..8d32b3e42 --- /dev/null +++ b/tests/PHPStan/Fixtures/SourceFiles/MarkedMethod.php @@ -0,0 +1,15 @@ +name)] + public function handle(): void + { + } +} diff --git a/tests/PHPStan/Fixtures/WorkerStateDependency/application/app/BaseReportListener.php b/tests/PHPStan/Fixtures/WorkerStateDependency/application/app/BaseReportListener.php new file mode 100644 index 000000000..1af5ecf2f --- /dev/null +++ b/tests/PHPStan/Fixtures/WorkerStateDependency/application/app/BaseReportListener.php @@ -0,0 +1,12 @@ +create(); +return Application::configure(basePath: dirname(__DIR__)) + ->booted(static function (Application $app): void { + $app->make('events')->listen(RequestHandled::class, ReportListener::class); + }) + ->create(); diff --git a/tests/PHPStan/Integration/IntegrationTest.php b/tests/PHPStan/Integration/IntegrationTest.php index 0f4a57f8a..2b13509e5 100644 --- a/tests/PHPStan/Integration/IntegrationTest.php +++ b/tests/PHPStan/Integration/IntegrationTest.php @@ -76,11 +76,14 @@ public static function dataIntegrationTests(): iterable yield 'bug-2582' => [__DIR__ . '/data/bug-2582.php']; - // Hypervel: a date attribute accepts what HasAttributes::fromDateTime() converts. + // Hypervel: a date attribute accepts what HasAttributes::fromDateTime() converts, and mutable dates are reported. yield 'bug-1744' => [ __DIR__ . '/data/bug-1744.php', [ + 11 => ['Carbon\Carbon::now() creates a mutable date.'], 13 => ['Property App\ModelWithDateColumnCasts::$created_at (DateTimeInterface|float|int|string|null) does not accept array.'], + 15 => ['Carbon\Carbon::now() creates a mutable date.'], + 18 => ['Carbon\Carbon::now() creates a mutable date.'], ], ]; @@ -187,12 +190,14 @@ public static function dataIntegrationTests(): iterable ]; // Hypervel: User's public email() accessor reads as mixed; date attributes read as CarbonInterface; SQLite - // has no unsigned columns; AsEnumCollection also stores the enum's backing values. + // has no unsigned columns; AsEnumCollection also stores the enum's backing values; mutable dates are reported. yield [ __DIR__ . '/data/model-properties.php', [ + 31 => ['Carbon\Carbon::now() creates a mutable date.'], 71 => ['Expected type string, actual: mixed'], 74 => ['Function testDateCast() should return Carbon\Carbon|null but returns Carbon\CarbonInterface|null.'], + 87 => ['Carbon\Carbon::now() creates a mutable date.'], 100 => ['Property App\User::$decimal (float|int|numeric-string) does not accept \'foo\'.'], 101 => ['Property App\User::$decimal (float|int|numeric-string) does not accept array{}.'], 110 => ['Property App\User::$nullable_decimal (float|int|numeric-string|null) does not accept \'foo\'.'], diff --git a/tests/PHPStan/ModelPropertyDependencyTest.php b/tests/PHPStan/ModelPropertyDependencyTest.php index c32d46d28..53aa13d51 100644 --- a/tests/PHPStan/ModelPropertyDependencyTest.php +++ b/tests/PHPStan/ModelPropertyDependencyTest.php @@ -4,11 +4,14 @@ namespace Hypervel\Tests\PHPStan; +use PHPUnit\Framework\Attributes\Large; + use function file_get_contents; use function file_put_contents; use function mkdir; use function str_replace; +#[Large] class ModelPropertyDependencyTest extends ResultCacheTestCase { protected string $fixture = 'ModelPropertyDependency'; diff --git a/tests/PHPStan/QueueJobDependencyTest.php b/tests/PHPStan/QueueJobDependencyTest.php index f2664311b..a88dd096c 100644 --- a/tests/PHPStan/QueueJobDependencyTest.php +++ b/tests/PHPStan/QueueJobDependencyTest.php @@ -4,10 +4,13 @@ namespace Hypervel\Tests\PHPStan; +use PHPUnit\Framework\Attributes\Large; + use function file_get_contents; use function file_put_contents; use function str_replace; +#[Large] class QueueJobDependencyTest extends ResultCacheTestCase { protected string $fixture = 'QueueJobDependency'; diff --git a/tests/PHPStan/ResultCacheTestCase.php b/tests/PHPStan/ResultCacheTestCase.php index cd48d4072..3f10467da 100644 --- a/tests/PHPStan/ResultCacheTestCase.php +++ b/tests/PHPStan/ResultCacheTestCase.php @@ -62,6 +62,8 @@ protected function copyApplication(): string /** * Run PHPStan from the application over its app directory, with the result and migration caches * and the opt-in rules the fixtures exercise on, plus any extra top-level configuration and parameters. + * Typed getter suggestions are off, since the fixtures read config through config() and get() to + * exercise their types. * * PHPStan uses its result cache only when it analyzes a directory, and reports whether it reused * the cache on standard error at the very verbose level. The application's autoloader is the @@ -91,6 +93,7 @@ protected function analyse( tmpDir: {$applicationPath}/cache enableMigrationCache: true checkBatchableJobChecksCancellation: true + checkTypedConfigGetters: false featureToggles: reuseCollectedDataRuleErrors: {$reuse} paths: diff --git a/tests/PHPStan/Rules/BindingCycleRuleTest.php b/tests/PHPStan/Rules/BindingCycleRuleTest.php new file mode 100644 index 000000000..3a7e626f4 --- /dev/null +++ b/tests/PHPStan/Rules/BindingCycleRuleTest.php @@ -0,0 +1,40 @@ + */ +class BindingCycleRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(BindingCycleRule::class); + } + + public function testRule(): void + { + $error = static fn (string $method, string $abstract, string $concrete, int $line): array => [ + "{$method}() binds '{$abstract}' to Hypervel\\{$concrete}, which is an alias of '{$abstract}', so resolving it never ends.", + $line, + 'Bind a closure that creates ' . class_basename($concrete) . ' with new instead of its class name.', + ]; + + $this->analyse([__DIR__ . '/data/binding-cycles.php'], [ + $error('singleton', 'auth', 'Auth\AuthManager', 25), + $error('bind', 'cache', 'Cache\CacheManager', 26), + $error('scoped', 'auth', 'Auth\AuthManager', 27), + $error('singleton', 'cache', 'Cache\CacheManager', 28), + $error('bind', 'auth', 'Auth\AuthManager', 39), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/ConfigCollectionRuleTest.php b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php index 9b8c27414..96f11322c 100644 --- a/tests/PHPStan/Rules/ConfigCollectionRuleTest.php +++ b/tests/PHPStan/Rules/ConfigCollectionRuleTest.php @@ -21,19 +21,19 @@ public function testRule(): void $this->analyse([__DIR__ . '/data/config-collection-rule.php'], [ [ "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", - 12, + 11, ], [ "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", - 19, + 18, ], [ "Config key 'test.foo' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", - 20, + 19, ], [ "Config key 'test.missing' is not an array, so collection() throws. Correct the config value or use the getter that matches its type.", - 26, + 25, ], ]); } diff --git a/tests/PHPStan/Rules/Coroutine/BorrowedResourceEscapeRuleTest.php b/tests/PHPStan/Rules/Coroutine/BorrowedResourceEscapeRuleTest.php new file mode 100644 index 000000000..790ca3ef9 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/BorrowedResourceEscapeRuleTest.php @@ -0,0 +1,198 @@ + */ +class BorrowedResourceEscapeRuleTest extends RuleTestCase +{ + private const string BORROW_TIP = 'Finish using it inside the callback, which holds the borrow.'; + + private const string PIN_TIP = 'Finish using it inside the callback, which holds the pin.'; + + private const string SPAWN_TIP = ' A child coroutine that must use it can run through wait() with waitForChildTermination: true.'; + + private const string GENERATOR_TIP = 'A generator callback only runs once it is iterated, after the %s ends, so return the results instead of yielding them.'; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(BorrowedResourceEscapeRule::class); + } + + public function testRule(): void + { + $borrowed = static fn (string $subject, string $how, int $line): array => [ + $subject . ' ' . $how . ', so it can be used after the borrow ends.', + $line, + $how === 'is used in a generator callback' + ? sprintf(self::GENERATOR_TIP, 'borrow') + : self::BORROW_TIP . (str_starts_with($how, 'is captured') ? self::SPAWN_TIP : ''), + ]; + $pinned = static fn (string $subject, string $how, int $line): array => [ + $subject . ' ' . $how . ', so it can be used after the pin ends.', + $line, + $how === 'is used in a generator callback' + ? sprintf(self::GENERATOR_TIP, 'pin') + : self::PIN_TIP . (str_starts_with($how, 'is captured') ? self::SPAWN_TIP : ''), + ]; + $connection = 'The Redis connection borrowed by withConnection()'; + $client = 'The Redis client of the Redis connection borrowed by withConnection()'; + $captured = static fn (string $spawn, int $line): array => $borrowed($connection, "is captured by a closure passed to {$spawn}()", $line); + + $this->analyse([__DIR__ . '/data/borrowed-resources.php'], [ + $borrowed($connection, 'is returned', 71), + $borrowed($client, 'is returned', 73), + $borrowed($connection, 'is returned', 77), + $borrowed('The SQS client of the SQS queue borrowed by withConnection()', 'is returned', 79), + $borrowed('The Flysystem driver borrowed by withDriver()', 'is returned', 80), + $borrowed('The filesystem client borrowed by withClient()', 'is returned', 81), + $borrowed('The filesystem client borrowed by withClient()', 'is returned', 83), + $borrowed($connection, 'is stored on a property', 92), + $borrowed($client, 'is stored on a property', 93), + $borrowed($connection, 'is stored on a property', 94), + $borrowed($client, 'is stored in a by-reference variable', 95), + $captured('go', 102), + $captured('Coroutine::create', 104), + $captured('parallel', 105), + $captured('parallel', 108), + $captured('Parallel::add', 110), + $captured('Parallel::add', 111), + $captured('Concurrency::run', 112), + $captured('Coroutine::defer', 113), + $captured('wait', 114), + $captured('go', 125), + $pinned('The PDO taken inside withPinnedSession()', 'is returned', 138), + $pinned('The read PDO taken inside withPinnedSession()', 'is returned', 140), + $pinned('The PDO taken inside withPinnedSession()', 'is returned', 141), + $pinned('The PDO taken inside withPinnedSession()', 'is captured by a closure passed to go()', 145), + $captured('Timer::after', 180), + $captured('Concurrent::create', 181), + $captured('Parallel::add', 182), + $captured('wait', 186), + $borrowed($connection, 'is returned', 199), + $borrowed($connection, 'is returned', 201), + $borrowed($connection, 'is stored on a property', 203), + $borrowed($connection, 'is stored on a property', 204), + $borrowed($connection, 'is stored on a property', 208), + $borrowed($connection, 'is stored on a property', 213), + $borrowed($connection, 'is stored on a property', 216), + $borrowed($connection, 'is returned', 216), + $captured('parallel', 219), + $pinned('The PDO taken inside withPinnedSession()', 'is returned', 249), + $pinned('The PDO taken inside withPinnedSession()', 'is stored on a property', 251), + $pinned('The PDO taken inside withPinnedSession()', 'is stored on a property', 274), + $pinned('The PDO taken inside withPinnedSession()', 'is stored on a property', 279), + $pinned('The PDO taken inside withPinnedSession()', 'is stored on a property', 287), + $borrowed($connection, 'is stored on a property', 336), + $captured('go', 345), + $captured('go', 350), + $captured('go', 356), + $borrowed($connection, 'is stored in a by-reference variable', 363), + $borrowed($client, 'is stored on a property', 369), + $borrowed($client, 'is captured by a closure passed to go()', 383), + $borrowed($connection, 'is stored on a property', 387), + $borrowed($client, 'is captured by a closure passed to go()', 396), + $captured('go', 405), + $borrowed($client, 'is captured by a closure passed to go()', 416), + $borrowed($connection, 'is stored in a global variable', 571), + $borrowed($client, 'is stored in a global variable', 574), + $borrowed($client, 'is stored on a property', 578), + $borrowed($connection, 'is stored in a global variable', 583), + $borrowed($connection, 'is stored in a by-reference variable', 592), + $borrowed($client, 'is stored on a property', 600), + $borrowed($client, 'is stored on a property', 606), + $borrowed($client, 'is stored on a property', 612), + $borrowed($client, 'is captured by a closure passed to go()', 622), + $borrowed($client, 'is captured by a closure passed to go()', 631), + $borrowed($client, 'is captured by a closure passed to go()', 642), + $borrowed($client, 'is stored in a global variable', 650), + $borrowed($client, 'is returned', 661), + $borrowed($client, 'is returned', 670), + $borrowed($client, 'is captured by a closure passed to go()', 682), + $borrowed($client, 'is returned', 694), + $borrowed($client, 'is returned', 706), + $borrowed($client, 'is returned', 718), + $borrowed($client, 'is returned', 732), + $captured('go', 742), + $borrowed($client, 'is returned', 754), + $borrowed($client, 'is returned', 766), + $borrowed($client, 'is returned', 781), + $borrowed($client, 'is returned', 793), + $borrowed($client, 'is returned', 809), + $borrowed($client, 'is returned', 822), + $borrowed($client, 'is returned', 832), + $borrowed($client, 'is stored on a property', 1167), + $borrowed($client, 'is stored in a global variable', 1168), + $borrowed($client, 'is stored in a by-reference variable', 1171), + $borrowed($client, 'is stored on a property', 1175), + $borrowed($client, 'is stored on a property', 1176), + $borrowed($client, 'is stored on a property', 1177), + $borrowed($client, 'is stored on a property', 1178), + $captured('go', 1182), + $captured('go', 1186), + $borrowed($client, 'is stored on a property', 1190), + $borrowed($client, 'is stored on a property', 1191), + $borrowed($connection, 'is stored in a by-reference variable', 1195), + $borrowed($connection, 'is stored in a global variable', 1199), + $borrowed($connection, 'is stored in a global variable', 1201), + $borrowed($connection, 'is stored in a global variable', 1204), + $borrowed($connection, 'is stored in a global variable', 1209), + $borrowed($client, 'is returned', 1218), + $borrowed($client, 'is returned', 1268), + $borrowed($client, 'is returned', 1282), + $borrowed($connection, 'is stored in a global variable', 1288), + $borrowed($client, 'is captured by a closure passed to go()', 1301), + $borrowed($client, 'is returned', 1311), + $borrowed($client, 'is captured by a closure passed to go()', 1320), + $borrowed($client, 'is returned', 1331), + $borrowed($client, 'is stored on a property', 1343), + $borrowed($connection, 'is stored on a property', 1355), + $borrowed($client, 'is returned', 1367), + $borrowed($client, 'is returned', 1382), + $borrowed($client, 'is returned', 1397), + $borrowed($client, 'is returned', 1406), + $borrowed($client, 'is returned', 1415), + $borrowed($client, 'is returned', 1424), + $borrowed($connection, 'is returned', 1439), + $borrowed($connection, 'is returned', 1450), + $borrowed($connection, 'is returned', 1461), + $borrowed($connection, 'is returned', 1473), + $borrowed($client, 'is returned', 1483), + $borrowed($client, 'is returned', 1493), + $borrowed($client, 'is returned', 1503), + $borrowed($client, 'is returned', 1512), + $borrowed($client, 'is returned', 1523), + $borrowed($client, 'is stored on a property', 1538), + $borrowed($client, 'is stored on a property', 1550), + $borrowed($client, 'is stored on a property', 1564), + $borrowed($client, 'is stored on a property', 1581), + $borrowed($connection, 'is stored on a property', 1592), + $borrowed($connection, 'is stored on a property', 1601), + $borrowed($client, 'is returned', 1608), + $borrowed($client, 'is returned', 1614), + $borrowed($client, 'is returned', 1620), + $borrowed($connection, 'is returned', 1630), + $borrowed($client, 'is returned', 1636), + $borrowed($connection, 'is captured by a closure passed to go()', 1642), + $borrowed($connection, 'is captured by a closure passed to go()', 1645), + $borrowed($connection, 'is captured by a closure passed to go()', 1649), + $borrowed($connection, 'is captured by a closure passed to go()', 1654), + $borrowed($connection, 'is stored on a property', 1659), + $borrowed($client, 'is captured by a closure passed to go()', 1665), + $borrowed($client, 'is captured by a closure passed to go()', 1672), + $borrowed($connection, 'is used in a generator callback', 1674), + $borrowed($connection, 'is used in a generator callback', 1677), + $pinned('The PDO taken inside withPinnedSession()', 'is used in a generator callback', 1678), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/ExactCancellationCatchRuleTest.php b/tests/PHPStan/Rules/Coroutine/ExactCancellationCatchRuleTest.php index 01dbcfb55..3b1ed1cbf 100644 --- a/tests/PHPStan/Rules/Coroutine/ExactCancellationCatchRuleTest.php +++ b/tests/PHPStan/Rules/Coroutine/ExactCancellationCatchRuleTest.php @@ -28,9 +28,13 @@ public function testRule(): void [sprintf(self::MESSAGE, 54), 52], [sprintf(self::MESSAGE, 74), 72], [sprintf(self::MESSAGE, 85), 83], - [sprintf(self::MESSAGE, 186), 184], - [sprintf(self::MESSAGE, 201), 199], - [sprintf(self::MESSAGE, 214), 212], + [sprintf(self::MESSAGE, 169), 167], + [sprintf(self::MESSAGE, 220), 218], + [sprintf(self::MESSAGE, 235), 233], + [sprintf(self::MESSAGE, 248), 246], + [sprintf(self::MESSAGE, 339), 337], + [sprintf(self::MESSAGE, 352), 350], + [sprintf(self::MESSAGE, 377), 375], ]); } diff --git a/tests/PHPStan/Rules/Coroutine/NoCoroutinesInTransactionsRuleTest.php b/tests/PHPStan/Rules/Coroutine/NoCoroutinesInTransactionsRuleTest.php index a77792a54..d1dffc164 100644 --- a/tests/PHPStan/Rules/Coroutine/NoCoroutinesInTransactionsRuleTest.php +++ b/tests/PHPStan/Rules/Coroutine/NoCoroutinesInTransactionsRuleTest.php @@ -41,6 +41,7 @@ public function testRule(): void [self::MESSAGE, 87], [self::MESSAGE, 90], [self::MESSAGE, 97], + [self::MESSAGE, 104], ]); } diff --git a/tests/PHPStan/Rules/Coroutine/NoRawCoroutinesRuleTest.php b/tests/PHPStan/Rules/Coroutine/NoRawCoroutinesRuleTest.php new file mode 100644 index 000000000..f6df66735 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/NoRawCoroutinesRuleTest.php @@ -0,0 +1,41 @@ + */ +class NoRawCoroutinesRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoRawCoroutinesRule::class); + } + + public function testRule(): void + { + $error = static fn (string $spawn, int $line): array => [ + "{$spawn}() starts a coroutine without the framework's exception handling, so an uncaught exception or cancellation stops the worker.", + $line, + "Use Hypervel\\Coroutine\\Coroutine::create(), go() or co() instead. Coroutine::fork() and the helpers' copyContext argument copy the parent's context. Keep a direct call, with an ignore, only when its callback catches every exception itself.", + ]; + + $this->analyse([__DIR__ . '/data/raw-coroutines.php'], [ + $error('Swoole\Coroutine::create', 20), + $error('Swoole\Coroutine\go', 21), + $error('Hypervel\Engine\Coroutine::create', 22), + $error('Hypervel\Engine\Coroutine::execute', 23), + $error('Hypervel\Engine\Coroutine::create', 24), + $error('Hypervel\Engine\Coroutine::execute', 25), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/RestrictedAttributeRuleTest.php b/tests/PHPStan/Rules/Coroutine/RestrictedAttributeRuleTest.php new file mode 100644 index 000000000..21a4facaf --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/RestrictedAttributeRuleTest.php @@ -0,0 +1,57 @@ + */ +class RestrictedAttributeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(RestrictedAttributeRule::class); + } + + public function testRule(): void + { + $registry = Registry::class; + + $this->analyse([__DIR__ . '/data/restricted-attributes.php'], [ + [ + "#[BootOnly] on {$registry}::both() sets both whenNotDefault and whenProvided, but a marker takes one parameter condition.", + 13, + 'Keep the condition that decides whether a call is restricted.', + ], + [ + "#[BootOnly] on {$registry}::missingDefault() names \$missing in whenNotDefault, but the method has no such parameter.", + 18, + 'Name the parameter whose argument makes a call restricted.', + ], + [ + "#[TestsOnly] on {$registry}::missingProvided() names \$missing in whenProvided, but the method has no such parameter.", + 23, + 'Name the parameter whose argument makes a call restricted.', + ], + [ + "#[BootOnly] on {$registry}::required() names \$map in whenNotDefault, but \$map has no default, so every call is restricted.", + 28, + 'Name an optional parameter, or remove the condition.', + ], + [ + "#[BootOrTestsOnly] on {$registry}::staticShared() sets whenShared, but a static method has no instance for the container to share.", + 33, + 'Remove whenShared, so the restriction applies to every call.', + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/RestrictedCallRuleTest.php b/tests/PHPStan/Rules/Coroutine/RestrictedCallRuleTest.php new file mode 100644 index 000000000..8e23c84ca --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/RestrictedCallRuleTest.php @@ -0,0 +1,246 @@ + */ +class RestrictedCallRuleTest extends RuleTestCase +{ + public const string MESSAGE = 'Call to #[%s] method %s()%s %s.'; + + public const string BOOT_TIP = "Call it while the application boots, such as in a service provider's boot() method, and keep per-request state in CoroutineContext or a scoped() binding."; + + private const string TESTS_TIP = 'Call it only from tests.'; + + private const string BOOT_OR_TESTS_TIP = "Call it while the application boots, such as in a service provider's boot() method, or in a test, and keep per-request state in CoroutineContext or a scoped() binding."; + + private const string SHARED_TIP = ' Or call it on an instance of your own.'; + + private const string SHARED = ' on the worker-shared instance'; + + private const string REQUEST = 'while handling a request'; + + private const string JOB = 'while running a job'; + + private ?WorkerStateHelper $helper = null; + + private ?FacadeHelper $facadeHelper = null; + + protected function getRule(): Rule + { + // Both rules, so a configuration write gets exactly one diagnostic. + return new CompositeRule([new WorkerStateRule($this->getHelper()), new RestrictedCallRule($this->getHelper())]); + } + + /** @return array> */ + protected function getCollectors(): array + { + $helper = $this->getHelper(); + + return [ + new RestrictedCallCollector($this->createReflectionProvider(), $helper, $this->getFacadeHelper()), + new WorkerStateWriteCollector($helper), + new WorkerStateCallCollector($this->createReflectionProvider(), $helper), + new WorkerStateClassCollector($this->createReflectionProvider(), $helper, self::getContainer()->getByType(RuntimeInputs::class)), + new WorkerStateParameterCollector($helper), + new WorkerStateConfigOffsetCollector($helper), + new WorkerStateGlobalWriteCollector($helper), + ]; + } + + public function testRule(): void + { + $registry = Registry::class; + $boot = static fn (string $method, string $condition, int $line, string $context = self::REQUEST): array => [ + sprintf(self::MESSAGE, 'BootOnly', $registry . '::' . $method, $condition, $context), + $line, + self::BOOT_TIP, + ]; + $fake = [sprintf(self::MESSAGE, 'TestsOnly', $registry . '::fake', '', self::REQUEST), self::TESTS_TIP]; + $configure = static fn (int $line, string $context = self::REQUEST): array => [ + sprintf(self::MESSAGE, 'BootOrTestsOnly', $registry . '::configure', self::SHARED, $context), + $line, + self::BOOT_OR_TESTS_TIP . self::SHARED_TIP, + ]; + + $config = static fn (int $line): array => [ + 'Configuration is changed while handling a request, which changes it for the whole worker.', + $line, + 'Set configuration during boot, and keep per-request values in CoroutineContext.', + ]; + + $this->analyse([__DIR__ . '/data/restricted-calls.php'], [ + $boot('register', '', 86), + [$fake[0], 87, $fake[1]], + [$fake[0], 88, $fake[1]], + $configure(92), + $configure(94), + $configure(97), + $configure(98), + [sprintf(self::MESSAGE, 'BootOnly', Factory::class . '::globalOptions', self::SHARED, self::REQUEST), 116, self::BOOT_TIP . self::SHARED_TIP], + [sprintf(self::MESSAGE, 'BootOnly', DateFactory::class . '::useClass', '', self::REQUEST), 117, self::BOOT_TIP], + $config(118), + [sprintf(self::MESSAGE, 'BootOnly', ConfigRepository::class . '::macro', '', self::REQUEST), 119, self::BOOT_TIP], + $boot('map', ' with a non-default $map', 126), + $boot('map', ' with a non-default $map', 127), + $boot('map', ' possibly with a non-default $map', 128), + $boot('map', ' possibly with a non-default $map', 129), + $boot('remember', ' with $duration', 131), + $boot('remember', ' with $duration', 132), + $boot('remember', ' possibly with $duration', 134), + $configure(148, self::JOB), + $boot('register', '', 157, 'during an operation'), + $boot('register', '', 170), + $configure(204), + $configure(207), + $config(210), + $config(211), + $configure(251, self::JOB), + $configure(299, self::JOB), + $configure(310, self::JOB), + $configure(331, self::JOB), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } + + /** + * Get the helper the rules and collectors share, reading the test's booted application. + */ + private function getHelper(): WorkerStateHelper + { + if ($this->helper === null) { + $this->helper = new WorkerStateHelper( + $this->createReflectionProvider(), + self::getContainer()->getByType(CoroutineRuleHelper::class), + self::getContainer()->getByType(RuntimeInputs::class), + checkWorkerSharedState: true, + checkStaticStateMutation: true, + checkUnboundedWorkerCaches: true, + noRuntimeConfigMutation: true, + checkRestrictedCalls: true, + checkCancellationHandling: false, + ); + + $this->helper->setContainer($this->createApplication()); + } + + return $this->helper; + } + + /** + * Get the facade helper reading the test's booted application. + */ + private function getFacadeHelper(): FacadeHelper + { + if ($this->facadeHelper === null) { + $this->facadeHelper = new FacadeHelper( + $this->createReflectionProvider(), + self::getContainer()->getService('cachedRichParser'), + self::getContainer()->getByType(RuntimeInputs::class), + ); + + $this->facadeHelper->setContainer($this->getHelper()->getContainer()); + } + + return $this->facadeHelper; + } + + /** + * Create the booted application the lifetimes and facade roots are read from. + */ + private function createApplication(): Container + { + $container = new Container; + $container->instance('config', new ConfigRepository); + $container->alias('config', ConfigRepository::class); + $container->alias('config', Repository::class); + $events = new Dispatcher($container); + $router = new Router($events, $container); + + $container->instance('events', $events); + $container->instance('router', $router); + + $router->get('/reports', [ReportController::class, 'index']); + $router->get('/reports/update', [ReportController::class, 'update']); + $router->get('/framework', [ReportController::class, 'framework']); + $events->listen(OnReceive::class, ReceiveListener::class); + + // Another route running the same action through a route binder leaves its injection unknown. + $router->bind('report', static fn (string $value): Registry => new Registry); + $router->get('/receivers', [ReceiverController::class, 'index']); + $router->get('/receivers/replace', [ReceiverController::class, 'replace']); + $router->get('/receivers/{report}', [ReceiverController::class, 'show']); + $router->get('/receivers/show', [ReceiverController::class, 'show']); + $router->get('/config', [ContextualConfigController::class, 'update']); + $events->listen('restricted.recorded', QueuedRegistryListener::class . '@record'); + $events->listen('restricted.inherited', InheritedRegistryListener::class . '@record'); + $events->listen(RequestHandled::class, OptionRegistryListener::class); + + $container->singleton('restricted.registry', static fn (): Registry => new Registry); + $container->singleton('restricted.unknown', static function (): Registry { + $registry = new Registry; + + return $registry; + }); + $container->bind(FreshRegistry::class); + $container->singleton(FactoryRegistry::class, static fn (): FactoryRegistry => new FactoryRegistry); + $container->singleton(ContextualRegistry::class, static fn (): ContextualRegistry => new ContextualRegistry); + $container->when(ReceiverController::class)->needs(Registry::class)->give(FreshRegistry::class); + $container->when(ReceiverController::class)->needs(ContextualRegistry::class)->give(static fn (): ContextualRegistry => new ContextualRegistry); + $container->when(ContextualConfigController::class)->needs(Repository::class)->give(static fn (): ConfigRepository => new ConfigRepository); + + // Each pair of jobs shares one handler, listed in the order where keeping one job's injection would report. + $container->when(FreshRegistryJob::class)->needs(Registry::class)->give(FreshRegistry::class); + $container->when(UnknownRegistryTask::class)->needs(Registry::class)->give(static fn (): Registry => new Registry); + + return $container; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/RestrictedCallsOnlyTest.php b/tests/PHPStan/Rules/Coroutine/RestrictedCallsOnlyTest.php new file mode 100644 index 000000000..84ed8d192 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/RestrictedCallsOnlyTest.php @@ -0,0 +1,105 @@ + + */ +class RestrictedCallsOnlyTest extends RuleTestCase +{ + protected function getRule(): Rule + { + $application = $this->createApplication(); + + self::getContainer()->getByType(WorkerStateHelper::class)->setContainer($application); + self::getContainer()->getByType(FacadeHelper::class)->setContainer($application); + + return new CompositeRule([ + self::getContainer()->getByType(WorkerStateRule::class), + self::getContainer()->getByType(RestrictedCallRule::class), + ]); + } + + /** @return array> */ + protected function getCollectors(): array + { + return [ + self::getContainer()->getByType(RestrictedCallCollector::class), + self::getContainer()->getByType(WorkerStateWriteCollector::class), + self::getContainer()->getByType(WorkerStateCallCollector::class), + self::getContainer()->getByType(WorkerStateClassCollector::class), + self::getContainer()->getByType(WorkerStateParameterCollector::class), + self::getContainer()->getByType(WorkerStateConfigOffsetCollector::class), + self::getContainer()->getByType(WorkerStateGlobalWriteCollector::class), + ]; + } + + public function testHelperCallsAreReportedWithoutStateChecks(): void + { + $this->analyse([__DIR__ . '/data/restricted-calls-only.php'], [ + [ + sprintf(RestrictedCallRuleTest::MESSAGE, 'BootOnly', Registry::class . '::register', '', 'while handling a request'), + 27, + RestrictedCallRuleTest::BOOT_TIP, + ], + [ + sprintf(RestrictedCallRuleTest::MESSAGE, 'BootOnly', ConfigRepository::class . '::macro', '', 'while handling a request'), + 29, + RestrictedCallRuleTest::BOOT_TIP, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/restricted-calls-only.neon']; + } + + /** + * Create the booted application the lifetimes and facade roots are read from. + */ + private function createApplication(): Container + { + $container = new Container; + $container->instance('config', new ConfigRepository); + $events = new Dispatcher($container); + $router = new Router($events, $container); + + $container->instance('events', $events); + $container->instance('router', $router); + + $router->post('/settings', [SettingsController::class, 'update']); + + return $container; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/SwallowedCancellationRuleTest.php b/tests/PHPStan/Rules/Coroutine/SwallowedCancellationRuleTest.php new file mode 100644 index 000000000..a083088bd --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/SwallowedCancellationRuleTest.php @@ -0,0 +1,133 @@ + */ +class SwallowedCancellationRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + // Both rules, so a catch the exact-catch rule reports gets exactly one diagnostic. + return new CompositeRule([ + self::getContainer()->getByType(ExactCancellationCatchRule::class), + self::getContainer()->getByType(SwallowedCancellationRule::class), + ]); + } + + /** @return array> */ + protected function getCollectors(): array + { + return [ + self::getContainer()->getByType(SwallowedCancellationCollector::class), + self::getContainer()->getByType(WorkerStateCallCollector::class), + self::getContainer()->getByType(WorkerStateClassCollector::class), + self::getContainer()->getByType(WorkerStateParameterCollector::class), + ]; + } + + public function testRule(): void + { + $swallowed = self::swallowed(...); + + $this->analyse([__DIR__ . '/data/swallowed-cancellations.php'], [ + $swallowed('a database query', 137), + $swallowed('a wait', 143), + $swallowed('a database query', 149), + $swallowed('a Redis command', 155), + $swallowed('a wait', 161), + $swallowed('a database query', 167), + $swallowed('a wait', 211), + $swallowed('a wait', 217), + $swallowed('a wait', 223), + $swallowed('a wait', 237), + $swallowed('a database query', 266), + $swallowed('a database query', 278), + $swallowed('a wait', 285), + $swallowed('a database query', 308), + $swallowed('a database query', 315), + $swallowed('a database query', 322), + $swallowed('a database query', 347), + $swallowed('a database query', 353), + $swallowed('a database query', 359), + $swallowed('a database query', 365), + $swallowed('a database query', 371), + $swallowed('a database query', 377), + $swallowed('a database query', 383), + $swallowed('a database query', 389), + $swallowed('a database query', 395), + $swallowed('a database query', 401), + $swallowed('a database query', 407), + $swallowed('a wait', 424), + $swallowed('a wait', 437), + $swallowed('a database query', 443), + $swallowed('a database query', 459), + $swallowed('a database query', 465), + [ + 'Catching CanceledException misses cancellation carried by another exception, which the catch on line 481 handles as an ordinary failure. Check Coroutine::causedByCancellation() in that catch instead.', + 479, + 'A cleanup or callback failure in the try block can carry the cancellation as its previous exception.', + ], + $swallowed('a database query', 487), + $swallowed('a database query', 497), + $swallowed('a database query', 526), + $swallowed('a Redis command', 554), + $swallowed('a database query', 562), + $swallowed('a database query', 569), + $swallowed('a database query', 577), + $swallowed('a database query', 585), + $swallowed('a database query', 622), + $swallowed('a wait', 630), + $swallowed('a database query', 636), + $swallowed('a database query', 646), + $swallowed('a wait', 658), + $swallowed('a database query', 695), + $swallowed('a database query', 707), + $swallowed('a database query', 727), + $swallowed('a database query', 757), + ]); + } + + public function testConcurrencyWaitsAndPropertyReceivers(): void + { + $this->analyse([__DIR__ . '/data/swallowed-cancellation-waits.php'], [ + self::swallowed('a wait', 23), + self::swallowed('a wait', 30), + self::swallowed('a wait', 36), + self::swallowed('a database query', 44), + self::swallowed('a database query', 51), + ]); + } + + /** + * Build the diagnostic for a catch swallowing the cancellation of the operation in a job. + * + * @return array{string, int, string} + */ + private static function swallowed(string $operation, int $line): array + { + return [ + "This catch swallows the cancellation of {$operation} in its try block while running a job.", + $line, + 'Rethrow the exception, or rethrow it when Coroutine::causedByCancellation() is true, so the canceled work stops.', + ]; + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/SwooleTableInProviderRuleTest.php b/tests/PHPStan/Rules/Coroutine/SwooleTableInProviderRuleTest.php new file mode 100644 index 000000000..24d324270 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/SwooleTableInProviderRuleTest.php @@ -0,0 +1,33 @@ + */ +class SwooleTableInProviderRuleTest extends RuleTestCase +{ + private const string TIP = 'Create it from a BeforeServerStart listener, so only the server allocates it before starting its workers.'; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(SwooleTableInProviderRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/swoole-tables.php'], [ + ["A Swoole table created in a service provider's register() is allocated by every process that boots the application.", 18, self::TIP], + ["A Swoole table created in a service provider's boot() is allocated by every process that boots the application.", 29, self::TIP], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/WorkerStateRuleTest.php b/tests/PHPStan/Rules/Coroutine/WorkerStateRuleTest.php index 277dee1c8..b4cd4ea89 100644 --- a/tests/PHPStan/Rules/Coroutine/WorkerStateRuleTest.php +++ b/tests/PHPStan/Rules/Coroutine/WorkerStateRuleTest.php @@ -12,15 +12,19 @@ use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; use Hypervel\Contracts\Config\Repository; use Hypervel\Events\Dispatcher; +use Hypervel\HttpServer\Events\RequestHandled; +use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\PHPStan\Collectors\WorkerStateCallCollector; use Hypervel\PHPStan\Collectors\WorkerStateClassCollector; use Hypervel\PHPStan\Collectors\WorkerStateConfigOffsetCollector; +use Hypervel\PHPStan\Collectors\WorkerStateGlobalWriteCollector; use Hypervel\PHPStan\Collectors\WorkerStateParameterCollector; use Hypervel\PHPStan\Collectors\WorkerStateWriteCollector; use Hypervel\PHPStan\Rules\Coroutine\CoroutineRuleHelper; use Hypervel\PHPStan\Rules\Coroutine\WorkerStateHelper; use Hypervel\PHPStan\Rules\Coroutine\WorkerStateRule; use Hypervel\PHPStan\Support\RuntimeInputs; +use Hypervel\Queue\Events\JobProcessed; use Hypervel\Routing\Router; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\BaseRecordingController; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\BinderController; @@ -42,12 +46,37 @@ use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\OverridingRecordingController; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\ReportController; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\ReportEvent; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\RepositoryPropertyController; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\ScopedBoundService; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\ScopedController; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\ScopedHelperController; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\SendReportListener; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\ServiceContract; use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerState\SingletonBoundService; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateGlobals\GlobalsController; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\BoundOptionListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\DebouncedByDefaultListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\DebouncedListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\OptionListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\OverridingOptionListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\UndebouncedListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\UniqueOptionListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\BaseRegistryListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\CatchAllObserver; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\ClonedEvent; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\ClonedListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\CollectingListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\JobListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\ObservingClonedListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\Post; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\ProviderObserver; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\QueuedListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\QueuedObserver; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\Registry; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\RegistryListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\RequestListener; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\SharedRegistry; +use Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\WildcardJobListener; use PhpParser\Node; use PHPStan\Collectors\Collector; use PHPStan\Rules\Rule; @@ -89,6 +118,18 @@ class WorkerStateRuleTest extends RuleTestCase private const string JOB = 'while running a job'; + private const string OPERATION = 'during an operation'; + + private const string LISTENERS_NAMESPACE = 'Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListeners\\'; + + private const string OPTIONS_NAMESPACE = 'Hypervel\Tests\PHPStan\Rules\Coroutine\WorkerStateListenerOptions\\'; + + private const array GLOBALS_FILES = [ + __DIR__ . '/data/worker-state-globals.php', + __DIR__ . '/data/worker-state-globals-request.php', + __DIR__ . '/data/worker-state-globals-account.php', + ]; + /** * The checkWorkerSharedState, checkStaticStateMutation, checkUnboundedWorkerCaches and * noRuntimeConfigMutation switches. @@ -99,10 +140,10 @@ class WorkerStateRuleTest extends RuleTestCase protected function getRule(): Rule { - $rule = new WorkerStateRule($this->createReflectionProvider(), $this->createHelper(), self::getContainer()->getByType(RuntimeInputs::class)); - $rule->setContainer($this->createApplication()); + $helper = $this->createHelper(); + $helper->setContainer($this->createApplication()); - return $rule; + return new WorkerStateRule($helper); } /** @return array> */ @@ -113,9 +154,10 @@ protected function getCollectors(): array return [ new WorkerStateWriteCollector($helper), new WorkerStateCallCollector($this->createReflectionProvider(), $helper), - new WorkerStateClassCollector($helper), + new WorkerStateClassCollector($this->createReflectionProvider(), $helper, self::getContainer()->getByType(RuntimeInputs::class)), new WorkerStateParameterCollector($helper), new WorkerStateConfigOffsetCollector($helper), + new WorkerStateGlobalWriteCollector($helper), ]; } @@ -161,9 +203,136 @@ public function testRouteClosures(): void [$static[0], 41, $static[1]], [sprintf(self::CONFIG, self::REQUEST), 47, self::CONFIG_TIP], [$static[0], 54, $static[1]], + [sprintf(self::CONFIG, self::REQUEST), 62, self::CONFIG_TIP], + ]); + } + + public function testListenerAndObserverContexts(): void + { + $write = static fn (string $class, string $property, string $context, int $line): array => [ + sprintf(self::STATIC_WRITE, self::LISTENERS_NAMESPACE . $class, $property, $context), + $line, + self::STATIC_TIP, + ]; + + $jobWrite = static fn (string $class, string $method, string $property, int $line): array => [ + sprintf('%1$s::%2$s() writes $this->%3$s %4$s, but the container shares %1$s across concurrent requests.', self::LISTENERS_NAMESPACE . $class, $method, $property, self::JOB), + $line, + self::SHARED_WRITE_TIP, + ]; + $request = static fn (string $class, string $property, int $line): array => [ + sprintf(self::SHARED_STATE, self::LISTENERS_NAMESPACE . $class, $property, 'the current request'), + $line, + self::SHARED_STATE_TIP, + ]; + + $this->analyse([__DIR__ . '/data/worker-state-listeners.php'], [ + $write('RequestListener', 'seen', self::REQUEST, 24), + $write('JobListener', 'recorded', self::JOB, 37), + $write('QueuedListener', 'recorded', self::JOB, 50), + $write('ProviderObserver', 'created', self::OPERATION, 63), + $write('DirectObserver', 'audited', self::OPERATION, 90), + $write('RepeatedObserver', 'updated', self::OPERATION, 111), + $write('BaseObserver', 'deleted', self::OPERATION, 134), + $write('AuditObserver', 'archived', self::OPERATION, 147), + $write('WildcardJobListener', 'seen', self::JOB, 185), + $write('CatchAllObserver', 'observed', self::OPERATION, 198), + $write('QueuedObserver', 'observed', self::REQUEST, 215), + [sprintf(self::CONFIG, self::REQUEST), 216, self::CONFIG_TIP], + $jobWrite('ClonedListener', 'failed', 'failures', 275), + $request('ClonedListener', 'latest', 283), + $request('CollectingListener', 'values', 313), + $jobWrite('CollectingListener', 'handle', 'groups', 314), + $jobWrite('CollectingListener', 'handle', 'totals', 315), + $jobWrite('CollectingListener', 'count', 'values', 324), + $request('Registry', 'request', 337), + $request('BaseRegistry', 'request', 363), + $request('ObservingClonedListener', 'request', 395), + ]); + } + + public function testQueuedListenerOptions(): void + { + $write = static fn (string $class, string $property, int $line): array => [ + sprintf(self::STATIC_WRITE, self::OPTIONS_NAMESPACE . $class, $property, self::REQUEST), + $line, + self::STATIC_TIP, + ]; + $request = static fn (string $class, int $line): array => [ + sprintf(self::SHARED_STATE, self::OPTIONS_NAMESPACE . $class, 'request', 'the current request'), + $line, + self::SHARED_STATE_TIP, + ]; + + $this->analyse([__DIR__ . '/data/worker-state-listener-options.php'], [ + $write('OptionListener', 'queued', 47), + $write('OptionListener', 'queued', 61), + $request('OptionListener', 81), + $request('OverridingOptionListener', 114), + $write('UniqueOptionListener', 'locked', 136), + $write('UniqueOptionListener', 'locked', 146), + $write('DebouncedListener', 'debounced', 179), + $write('DebouncedByDefaultListener', 'debounced', 203), ]); } + public function testGlobalVariables(): void + { + $keeps = static fn (string $name, int $line, string $state = 'the current request'): array => [ + sprintf('Global variable %s keeps %s, which the whole worker then shares.', $name, $state), + $line, + self::STATIC_TIP, + ]; + $written = static fn (string $name, int $line): array => [ + sprintf('Global variable %s is written while handling a request, but global variables are shared by the whole worker.', $name), + $line, + self::STATIC_TIP, + ]; + + // The traits' writes are at the same position of their own files, which are both collected under the class's. + $this->analyse(self::GLOBALS_FILES, [ + $keeps("\$GLOBALS['current_request']", 25), + $written("\$GLOBALS['requests']", 26), + $written("\$GLOBALS['tenant']", 27), + $written('$tenant', 36), + $written('$settings', 42), + $written('$profile', 43), + $keeps('$settings', 44), + $written('$visits', 45), + $written("\$GLOBALS['visits']", 46), + $written('$previous', 47), + $written("\$GLOBALS['first']", 48), + $keeps('$current', 51), + $written('$position', 51), + $written('$listed', 54), + $keeps("\$GLOBALS['loop']", 57), + $written('$settings', 66), + $written('$failure', 70), + $written('$matches', 74), + $written("\$GLOBALS['list']", 75), + $written('$matches', 79), + $written("\$GLOBALS['filled']", 80), + $written('$profile', 81), + $keeps('$held', 100), + $keeps("\$GLOBALS['request']", 101), + $keeps('$forward', 127), + $keeps('$reverse', 129), + $keeps('$item', 132), + $keeps('$second', 135), + $keeps('$memo', 146), + $keeps("\$GLOBALS['memo']", 147), + $keeps('$kept', 12), + $keeps('$kept', 12, 'a user'), + ]); + } + + public function testGlobalVariablesFollowTheStaticStateSwitch(): void + { + $this->switches = [true, false, true, true]; + + $this->analyse(self::GLOBALS_FILES, []); + } + /** * Route actions at the same position of different files are different functions. */ @@ -233,6 +402,7 @@ private static function getExpectedErrors(): array $sharedWrite('HelperController', 'track', 'tracked', 750), $sharedWrite('OverridingRecordingController', 'record', 'overridden', 812), $sharedWrite('LexicalParentController', 'resolve', 'resolved', 830), + $sharedWrite('RepositoryPropertyController', 'update', 'settings', 914), ], 'static' => [ [sprintf(self::STATIC_STATE, self::NAMESPACE . 'SharedService', 'current', 'the current request'), 87, self::STATIC_TIP], @@ -240,6 +410,7 @@ private static function getExpectedErrors(): array $staticWrite('StoreReportRequest', 'validated', self::REQUEST, 588), $staticWrite('ProcessReport', 'runs', self::JOB, 603), $staticWrite('RebuildReport', 'rebuilt', self::JOB, 857), + $staticWrite('RepositoryPropertyController', 'defaults', self::REQUEST, 913), ], 'cache' => [ $cache('Property', 'SharedService', 'visits', 88), @@ -267,6 +438,11 @@ private static function getExpectedErrors(): array [sprintf(self::CONFIG, self::JOB), 858, self::CONFIG_TIP], [sprintf(self::CONFIG, self::JOB), 869, self::CONFIG_TIP], [sprintf(self::CONFIG, self::JOB), 871, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 883, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 884, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 886, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 887, self::CONFIG_TIP], + [sprintf(self::CONFIG, self::JOB), 890, self::CONFIG_TIP], ], ]; } @@ -279,7 +455,10 @@ private function createHelper(): WorkerStateHelper return new WorkerStateHelper( $this->createReflectionProvider(), self::getContainer()->getByType(CoroutineRuleHelper::class), + self::getContainer()->getByType(RuntimeInputs::class), ...$this->switches, + checkRestrictedCalls: false, + checkCancellationHandling: false, ); } @@ -311,8 +490,30 @@ private function createApplication(): Container $router->get('/scoped-helpers', [ScopedHelperController::class, 'index']); $router->get('/records', [BaseRecordingController::class, 'index']); $router->get('/lexical', [LexicalChildController::class, 'index']); + $router->get('/settings', [RepositoryPropertyController::class, 'update']); + $router->get('/globals', [GlobalsController::class, 'index']); $events->listen(ReportEvent::class, SendReportListener::class); + $events->listen(RequestReceived::class, RequestListener::class); + $events->listen(JobProcessed::class, JobListener::class . '@record'); + $events->listen(RequestHandled::class, [QueuedListener::class, 'record']); + $events->listen('eloquent.created: ' . Post::class, ProviderObserver::class . '@created'); + $events->listen('Hypervel\Queue\Events\Job*', WildcardJobListener::class); + $events->listen('*', CatchAllObserver::class . '@observe'); + $events->observe(RequestReceived::class, QueuedObserver::class); + $events->listen(ClonedEvent::class, ClonedListener::class); + $events->listen(ClonedEvent::class, CollectingListener::class); + $events->listen(ClonedEvent::class, RegistryListener::class); + $events->listen(ClonedEvent::class, BaseRegistryListener::class); + $events->listen(ClonedEvent::class, ObservingClonedListener::class); + // An application event has no known operation, but the observer still runs on the shared instance. + $events->observe('listeners.audited', ObservingClonedListener::class); + $events->listen(RequestHandled::class, OptionListener::class); + $events->listen(RequestHandled::class, BoundOptionListener::class); + $events->listen(RequestHandled::class, UniqueOptionListener::class); + $events->listen(RequestHandled::class, DebouncedListener::class); + $events->listen(RequestHandled::class, DebouncedByDefaultListener::class); + $events->listen(RequestHandled::class, UndebouncedListener::class); $bus->map([MappedCommand::class => MappedHandler::class]); $container->bind(BoundFreshService::class); @@ -321,10 +522,14 @@ private function createApplication(): Container $container->singleton(FactoryBoundService::class, static fn (): FactoryBoundService => new FactoryBoundService); $container->singleton(ServiceContract::class, ContractImplementation::class); $container->singleton(BaseRecordingController::class, OverridingRecordingController::class); + $container->singleton(BoundOptionListener::class, OverridingOptionListener::class); $container->bind(Authenticatable::class, User::class); $container->when(ConsumerService::class)->needs(ContextualDependency::class)->give(ContextualImplementation::class); $container->when(OtherConsumer::class)->needs(User::class)->give(static fn (): User => new User); $container->when(ContextualConfigJob::class)->needs(Repository::class)->give(LocalConfig::class); + // Shared on their own as well as through the cloned listeners that extend them. + $container->singleton(Registry::class); + $container->singleton(SharedRegistry::class); return $container; } diff --git a/tests/PHPStan/Rules/Coroutine/data/borrowed-resources.php b/tests/PHPStan/Rules/Coroutine/data/borrowed-resources.php new file mode 100644 index 000000000..1b5f62563 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/borrowed-resources.php @@ -0,0 +1,1939 @@ +redis->withConnection(fn (RedisConnection $connection) => $connection->client()); + $this->context->withConnection(function (RedisConnection $connection) { + $alias = $connection; + + return $alias; + }); + $this->queue->withConnection(fn (SqsQueue $queue) => $queue->getSqs()); + $this->disk->withDriver(fn (FilesystemOperator $driver) => $driver); + $this->cloud->withClient(fn ($client) => $client); + $this->local->withDriver(fn (FilesystemOperator $driver) => $driver); + Storage::disk('s3')->withClient(fn ($client) => $client); + } + + public function stores(): void + { + $held = null; + + Redis::withConnection(function (RedisConnection $connection) use (&$held) { + $client = $connection->client(); + $this->connection = $connection; + $this->clients['main'] = $client; + self::$shared = $connection; + $held = $client; + }); + } + + public function spawns(): void + { + Redis::withConnection(function (RedisConnection $connection) { + go(function () use ($connection) { + }); + Coroutine::create(fn () => $connection->get('a')); + parallel([fn () => $connection->get('b')]); + $tasks = []; + $tasks[] = fn () => $connection->get('c'); + parallel($tasks); + $parallel = new Parallel; + $parallel->add(fn () => $connection->get('d')); + $this->parallel->add(fn () => $connection->get('e')); + Concurrency::run(fn () => $connection->get('f')); + Coroutine::defer(fn () => $connection->get('g')); + wait(fn () => $connection->get('h')); + wait(fn () => $connection->get('i'), waitForChildTermination: true); + (new SyncDriver)->run(fn () => $connection->get('j')); + go(fn () => 'unrelated'); + }); + } + + public function nested(): void + { + Redis::withConnection(function (RedisConnection $connection) { + return array_map(function (string $key) use ($connection) { + go(fn () => $connection->get($key)); + + return $connection; + }, ['a']); + }); + } + + public function pinned(): void + { + $connection = $this->database; + $other = $this->database; + + $connection->withPinnedSession(function () use ($connection) { + return $connection->getPdo(); + }); + $connection->withPinnedSession(fn () => $connection->getReadPdo()); + DB::withPinnedSession(fn () => DB::getPdo()); + $connection->withPinnedSession(function () use ($connection) { + $pdo = $connection->getPdo(); + $pdo->exec('select 1'); + go(fn () => $pdo->exec('select 2')); + }); + $connection->withPinnedSession(fn () => $other->getPdo()); + $connection->withPinnedSession(function () use ($connection) { + $connection = $this->database; + + return $connection->getPdo(); + }); + } + + public function contained(): void + { + Redis::withConnection(function (RedisConnection $connection) { + $holder = new Holder; + $holder->value = $connection; + $items = [$connection]; + $items[] = $connection->client(); + + return $connection->get('key'); + }); + Redis::withConnection(function (RedisConnection $connection) { + $alias = $connection; + $alias = null; + + return $alias; + }); + $this->disk->withDriver(fn (FilesystemOperator $driver) => $driver->readStream('file')); + (new LendingService)->withConnection(fn (RedisConnection $connection) => $connection); + } + + public function dispatchForms(Timer $timer, Concurrent $concurrent, QueuedParallel $queued, InlineParallel $inline): void + { + $join = true; + + Redis::withConnection(function (RedisConnection $connection) use ($timer, $concurrent, $queued, $inline) { + $timer->after(1.0, fn () => $connection->get('k')); + $concurrent->create(fn () => $connection->get('l')); + $queued->add(fn () => $connection->get('m')); + $inline->add(fn () => $connection->get('n')); + wait(fn () => $connection->get('o'), waitForChildTermination: Joins::CHILD); + $join = false; + wait(fn () => $connection->get('p'), waitForChildTermination: $join); + }); + } + + public function overriddenGetters(ReplicaConnection $replica): void + { + DB::withPinnedSession(fn () => ExternalDB::getPdo()); + $replica->withPinnedSession(fn () => $replica->getPdo()); + } + + public function escapesThroughValues(Holder $holder): void + { + Redis::withConnection(function (RedisConnection $connection) { + return fn () => $connection->get('key'); + }); + Redis::withConnection(fn (RedisConnection $connection) => [$connection, 'main']); + Redis::withConnection(function (RedisConnection $connection) use ($holder) { + $this->connection = fn () => $connection->client(); + $holder->value = $connection; + $local = new Holder; + $local->value = $connection; + $lazy = fn () => $connection->get('later'); + $this->clients = [$lazy]; + array_map(function () use ($local, $connection) { + $local->value = $connection; + }, [1]); + array_map(function () use ($holder, $connection) { + $holder->value = $connection; + }, [1]); + }); + Redis::withConnection(fn (RedisConnection $connection) => $holder->value = $connection); + Redis::withConnection(function (RedisConnection $connection) { + $tasks = [fn () => $connection->get('q')]; + parallel($tasks); + go(fn () => array_map(fn ($connection) => $connection, ['safe'])); + go(fn () => new class { + public function key(): string + { + $connection = 'safe'; + + return $connection; + } + }); + }); + } +} + +class PinnedService +{ + private ?PDO $pdo = null; + + public PdoConnection $hooked { + get => $this->database; + } + + public function __construct( + private PdoConnection $database, + private PdoConnection $replica, + ) { + } + + public function pins(): void + { + $this->database->withPinnedSession(fn () => $this->database->getPdo()); + $this->database->withPinnedSession(function () { + $this->pdo = $this->database->getPdo(); + }); + $this->database->withPinnedSession(fn () => $this->replica->getPdo()); + $this->database->withPinnedSession(function () { + $this->database = $this->replica; + + return $this->database->getPdo(); + }); + $this->database->withPinnedSession(function () { + foreach ([$this->replica] as $this->database) { + } + + return $this->database->getPdo(); + }); + $this->hooked->withPinnedSession(fn () => $this->hooked->getPdo()); + } + + public function spawns(): void + { + $connection = $this->database; + + $connection->withPinnedSession(function () use ($connection) { + go(function () use ($connection) { + $this->pdo = $connection->getPdo(); + }); + }); + $this->database->withPinnedSession(function () { + go(function () { + $this->pdo = $this->database->getPdo(); + }); + }); + DB::withPinnedSession(function () { + go(function () { + $this->pdo = DB::getPdo(); + }); + $store = function () { + $this->pdo = DB::getPdo(); + }; + $store(); + }); + } +} + +class Joins +{ + public const bool CHILD = true; +} + +class QueuedParallel extends Parallel +{ +} + +class InlineParallel extends Parallel +{ + public function add(callable $callable, int|string|null $key = null): void + { + $callable(); + } +} + +class ExternalDB extends DB +{ + public static function getPdo(): PDO + { + return new PDO('sqlite::memory:'); + } +} + +class ReplicaConnection extends PdoConnection +{ + public function getPdo(): PDO + { + return new PDO('sqlite::memory:'); + } +} + +class OrderedEscapes +{ + private array $items = []; + + public function escapes(bool $cached, RedisConnection $other): void + { + Redis::withConnection(function (RedisConnection $connection) { + $items = []; + $items[] = $connection; + $this->items = $items; + }); + Redis::withConnection(function (RedisConnection $connection) use ($cached) { + $task = fn () => $connection->get('key'); + + if ($cached) { + $task = fn () => 'cached'; + } + + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $task = fn () => $connection->get('key'); + $connection = $other; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $task = function () use (&$connection) { + return $connection->get('key'); + }; + go($task); + }); + + $held = null; + + Redis::withConnection(function (RedisConnection $connection) use (&$held) { + array_map(function () use ($connection, &$held) { + $held = $connection; + }, [1]); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $store = function () use (&$client) { + $this->items = [$client]; + }; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $inner = function () use (&$client) { + return $client?->get('key'); + }; + $task = function () use ($inner) { + return $inner(); + }; + $inner = static fn () => null; + $client = $connection->client(); + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $items = [$connection]; + $this->items = &$items; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $inner = function () use (&$client) { + return $client->get('key'); + }; + $task = fn () => $inner(); + array_map(function () use ($task) { + go($task); + }, [1]); + }); + Redis::withConnection(function (RedisConnection $connection) { + $inner = function () use (&$connection) { + return $connection->get('key'); + }; + $task = fn () => $inner(); + $launch = function () use ($task) { + go($task); + }; + $launch(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $task = function () use (&$client) { + return $client?->get('key'); + }; + $launch = function () use ($task, &$client, $connection) { + $client = $connection->client(); + go($task); + }; + $launch(); + }); + } + + public function staysInside(RedisConnection $other): void + { + Redis::withConnection(function (RedisConnection $connection) { + $items = []; + $items[] = $connection; + $this->consume($items); + $items = []; + + return $items; + }); + Redis::withConnection(function (RedisConnection $connection) { + $task = fn () => $connection->get('key'); + $value = $task(); + $task = fn () => $value; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $task = function () use (&$connection) { + return $connection->get('key'); + }; + $connection = $other; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $task = function () use (&$connection) { + return $connection->get('key'); + }; + $connection = $other; + + return $task; + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $store = function () use (&$connection) { + $this->items = [$connection]; + }; + $connection = $other; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $inner = function () use (&$client) { + return $client?->get('key'); + }; + $task = function () use ($inner) { + return $inner(); + }; + $client = null; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $alias = &$connection; + $connection = $other; + + return $alias; + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $alias = &$connection; + $second = &$alias; + $connection = $other; + + return $second; + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $store = function () use (&$connection) { + $this->items = [$connection]; + }; + $alias = &$connection; + $alias = $other; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $inner = function () use (&$client) { + return $client?->get('key'); + }; + $task = fn () => $inner(); + $launch = function () use ($task) { + go($task); + }; + $client = $connection->client(); + $launch(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $task = function () use (&$client) { + return $client?->get('key'); + }; + $launch = function () use ($task, &$client) { + $client = null; + go($task); + }; + $launch(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $attempts = 0; + $task = function () use (&$client, &$attempts) { + ++$attempts; + + return $client?->get('key'); + }; + $launch = function () use ($task, $client, &$attempts) { + if ($client !== null) { + go($task); + } + }; + $client = null; + $launch(); + }); + } + + private function consume(array $items): void + { + } +} + +class ReferencedOwners +{ + public function __construct( + private PdoConnection $database, + private PdoConnection $replica, + ) { + } + + public function pins(): void + { + $connection = $this->database; + + $connection->withPinnedSession(function () use ($connection) { + $alias = &$connection; + $alias = $this->replica; + + return $connection->getPdo(); + }); + $this->database->withPinnedSession(function () { + $alias = &$this->database; + $alias = $this->replica; + + return $this->database->getPdo(); + }); + } +} + +class GlobalStores +{ + public function escapes(bool $shared): void + { + Redis::withConnection(function (RedisConnection $connection) { + global $held; + $held = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + $GLOBALS['held'] = $connection->client(); + }); + Redis::withConnection(function (RedisConnection $connection) { + global $registry; + $registry->client = $connection->client(); + }); + Redis::withConnection(function (RedisConnection $connection) { + global $held; + $store = function () use (&$held, $connection) { + $held = $connection; + }; + $store(); + }); + + $out = null; + + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + $store = function () use (&$out, $connection) { + $out = $connection; + }; + unset($out); + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + global $registry; + $store = function () use ($registry, $connection) { + $registry->value = $connection->client(); + }; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + global $registry; + $store = fn () => $registry->value = $connection->client(); + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + $out = new Holder; + $store = function () use ($out, $connection) { + $out->value = $connection->client(); + }; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $task = function () use (&$client) { + $client->ping(); + }; + unset($client); + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $task = function () use (&$client) { + $client->ping(); + }; + $other = null; + $client = &$other; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $inner = function () use (&$client) { + return $client; + }; + $task = function () use (&$inner) { + return $inner(); + }; + unset($inner); + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $task = function () use (&$client) { + return $client; + }; + global $client; + $client = $connection->client(); + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $other = null; + $client = &$other; + unset($client); + + if ($shared) { + $client = $connection->client(); + + return $client; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + return $client; + } finally { + unset($client); + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $task = function () use (&$client) { + $client->ping(); + }; + + try { + go($task); + } finally { + unset($client); + } + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + + if ($shared) { + $copy = $client; + unset($client); + + return $copy; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + + if ($shared) { + unset($clients); + $clients[] = $connection->client(); + + return $clients; + } + + return []; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = null; + + if ($shared) { + unset($client); + $client = $connection->client(); + + return $client; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $batch = []; + + foreach ([1, 2] as $row) { + unset($batch); + $batch = []; + $batch[] = $connection->client(); + } + + return $batch; + }); + Redis::withConnection(function (RedisConnection $connection) { + $inner = function () use (&$inner, &$connection) { + return $connection; + }; + $task = function () use (&$inner) { + return $inner(); + }; + unset($inner, $connection); + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients = [$connection->client()]; + $copy = $clients; + + foreach ($clients as &$client) { + $client = null; + } + + unset($client); + + return $copy; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + $clients[] = $connection->client(); + $result = []; + + if ($shared) { + unset($unrelated); + $result = $clients; + } + + return $result; + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients = []; + $result = []; + $copy = []; + $unrelated = null; + + foreach ([1, 2] as $row) { + $result = $clients; + unset($unrelated); + $clients[] = $connection->client(); + $copy = $result; + } + + return $copy; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + $label = null; + + if ($shared) { + unset($label); + $label = 'main'; + $task = fn () => [$label, $client]; + $client = null; + + return $task; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $tasks = []; + + foreach ([1, 2] as $row) { + unset($label); + $label = 'main'; + $tasks[] = fn () => [$label, $client]; + $client = null; + } + + return $tasks; + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients = []; + $label = null; + + foreach ([1, 2] as $row) { + unset($label); + $label = 'main'; + $task = fn () => [$label, $clients]; + $clients[] = $connection->client(); + } + + return $task; + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients = []; + + foreach ([1, 2] as $row) { + $task = fn () => $clients; + $clients[] = $connection->client(); + } + + return $task; + }); + } + + public function staysInside(bool $shared): void + { + $out = null; + + Redis::withConnection(function (RedisConnection $connection) { + $held = $connection; + global $held; + }); + Redis::withConnection(function (RedisConnection $connection) { + global $held; + unset($held); + $held = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + static $held; + $held = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + global $held; + $store = function () use ($connection) { + $held = $connection; + }; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + global $held; + $local = null; + $held = &$local; + $held = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + global $held; + static $held; + $held = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + $client = $connection->client(); + $out = &$client; + }); + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + $local = null; + $out = &$local; + $out = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + unset($out); + $out = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + global $registry; + $holder = new Holder; + $store = function () use ($holder, $connection) { + $holder->value = $connection->client(); + }; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $task = function () use (&$client) { + return $client; + }; + unset($client); + $client = $connection->client(); + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients[] = $connection->client(); + unset($clients); + $clients[] = 'done'; + + return $clients; + }); + Redis::withConnection(function (RedisConnection $connection) { + global $held; + + if (! function_exists(__NAMESPACE__ . '\\keepConnection')) { + function keepConnection(RedisConnection $connection): RedisConnection + { + $held = $connection; + + return $connection; + } + } + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + $task = function () use (&$client) { + return $client; + }; + + if ($shared) { + $client = null; + unset($client); + } + + $client = null; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $inner = function () use (&$client) { + return $client; + }; + $task = function () use (&$inner) { + return $inner(); + }; + unset($inner); + $client = null; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $other = null; + $client = &$other; + + if ($shared) { + unset($client); + } + + if ($shared) { + $client = $connection->client(); + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + if ($shared) { + $clients[] = $connection->client(); + unset($clients); + $clients[] = 'done'; + + return $clients; + } + + return []; + }); + Redis::withConnection(function (RedisConnection $connection) { + $batch = []; + + foreach ([1, 2] as $row) { + $batch[] = $connection->client(); + unset($batch); + $batch = []; + } + + return $batch; + }); + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + $items = [null]; + + foreach ($items as &$out) { + } + + $out = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $other = null; + $client = &$other; + + if ($shared) { + unset($client); + $client = $connection->client(); + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients = [$connection->client()]; + [&$client] = $clients; + $client = null; + + return $clients; + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients[] = $connection->client(); + + foreach ($clients as &$client) { + $client = null; + } + + unset($client); + + return $clients; + }); + Redis::withConnection(function (RedisConnection $connection) { + $clients = [$connection->client()]; + $store = function () use (&$clients) { + $this->clients = $clients; + }; + + foreach ($clients as &$client) { + $client = null; + } + + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + $clients[] = $connection->client(); + $result = []; + + if ($shared) { + unset($clients); + $clients = []; + $result = $clients; + } + + return $result; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + $clients[] = $connection->client(); + $result = []; + $copy = []; + $other = null; + + if ($shared) { + unset($clients); + $clients = []; + $result = $clients; + unset($other); + $copy = $result; + } + + return $copy; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = null; + $task = function () use (&$client) { + return $client; + }; + $copy = null; + + if ($shared) { + unset($client); + $client = $connection; + $copy = $task; + $this->callback = $copy; + } + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + $safe = null; + $unrelated = null; + + if ($shared) { + $client = &$safe; + unset($unrelated); + $copy = $client; + + return $copy; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + $clients[] = $connection->client(); + $result = []; + + foreach ([1, 2] as $row) { + if ($shared) { + unset($clients); + $clients = []; + $result = $clients; + } + } + + return $result; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + $clients[] = $connection->client(); + $result = []; + $copy = []; + $other = null; + + while ($shared) { + unset($clients); + $clients = []; + $result = $clients; + unset($other); + $copy = $result; + $shared = false; + } + + return $copy; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + $result = []; + $copy = []; + $unrelated = null; + + if ($shared) { + $result = $clients; + unset($unrelated); + $clients[] = $connection->client(); + $copy = $result; + $reset = function (): void { + $local = null; + }; + $clear = fn () => $local = null; + } + + return $copy; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + + if ($shared) { + $copy = [($client = null), $client]; + + return $copy; + } + + return null; + }); + } +} + +class StoreForms +{ + public mixed $client = null; + + public mixed $name = null; + + public function escapes(bool $shared): void + { + $out = null; + + Redis::withConnection(function (RedisConnection $connection) { + $this->client ??= $connection->client(); + $GLOBALS['held'] ??= $connection->client(); + }); + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + $out ??= $connection->client(); + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + $this->client = $shared ? $client : null; + $this->client = $client ?: null; + $this->client = $client ?? null; + $this->client = match ($shared) { + true => $client, + false => null, + }; + go($shared ? fn () => $connection->get('key') : fn () => null); + $task = $shared ? function () use ($connection) { + $connection->get('key'); + } : null; + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $shared ? $connection->client() : null; + [$this->client, $local] = [$connection->client(), 'local']; + $this->name = $client ??= 'other'; + }); + Redis::withConnection(function (RedisConnection $connection) use (&$out) { + $alias = &$out; + $alias = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + $alias = &$GLOBALS['held']; + $alias = $connection; + $GLOBALS['other'] = &$local; + $local = $connection; + $first = &$GLOBALS['chained']; + $second = &$first; + $second = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + $alias = &$GLOBALS['held']; + $store = function () use (&$alias, $connection) { + $alias = $connection; + }; + $alias = &$local; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $shared ? $connection->client() : null; + $client ??= 'other'; + + return $client; + }); + } + + public function staysInside(): void + { + Redis::withConnection(function (RedisConnection $connection) { + [$local, $this->name] = [$connection->client(), 'name']; + ['name' => $this->name] = ['client' => $connection->client(), 'name' => 'safe']; + }); + Redis::withConnection(function (RedisConnection $connection) { + global $held; + $held = &$local; + $local = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + $alias = &$GLOBALS['held']; + unset($alias); + $alias = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) { + $store = function () use (&$alias, $connection) { + $alias = $connection; + }; + $alias = &$GLOBALS['held']; + $store(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $items = [$connection]; + global $held; + [&$held] = $items; + $held = null; + }); + } +} + +class ControlFlow +{ + public mixed $client = null; + + public mixed $callback = null; + + public function escapes(bool $shared, int $mode, array $rows, ?RedisConnection $other): void + { + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + + if ($shared) { + unset($client); + } else { + return $client; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $clients = []; + + if ($shared) { + $clients[] = $connection->client(); + } else { + $clients = []; + } + + return $clients; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + if ($shared) { + global $held; + } + $held = $connection; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = null; + $task = function () use (&$client) { + return $client; + }; + + if ($shared) { + unset($client); + } + + $client = $connection->client(); + go($task); + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $clients = []; + + foreach ($rows as $row) { + $task = fn () => $clients; + $clients[] = $connection->client(); + } + + return $task; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = null; + $task = function () use (&$client) { + return $client; + }; + + foreach ($rows as $row) { + go($task); + $client = $connection->client(); + } + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows, $shared) { + $clients = []; + + foreach ($rows as $row) { + $clients[] = $connection->client(); + + if ($shared) { + return $clients; + } + + $clients = []; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = $connection->client(); + + foreach ($rows as $row) { + $this->client = $client; + $client = null; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $task = function () use (&$client) { + return $client; + }; + + for ($index = 0; $index < 2; ++$index) { + $client = $connection; + $this->callback = $task; + } + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $found = null; + + foreach ($rows as $row) { + $found = $connection->client(); + + break; + } + + return $found; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = null; + + foreach ($rows as $row) { + foreach ($rows as $inner) { + $client = $connection->client(); + + break 2; + } + + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $last = null; + + foreach ($rows as $row) { + $last = $connection->client(); + + if ($row) { + continue; + } + + $last = null; + } + + return $last; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = $connection->client(); + + foreach ($rows as $row) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = null; + + do { + $client = $connection->client(); + } while ($shared); + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = $connection->client(); + + foreach ([...$rows] as $row) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $keys = ['key']; + + if ($rows === []) { + $keys = []; + } + + $client = $connection; + + foreach ($keys as $key) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $keys = ['key']; + unset($keys[0]); + $client = $connection; + + foreach ($keys as $key) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $keys = ['key']; + array_pop($keys); + $client = $connection; + + foreach ($keys as $key) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $alias = &$keys; + $keys = ['key']; + $alias = []; + $client = $connection; + + foreach ($keys as $key) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($mode) { + $client = null; + + switch ($mode) { + case 1: + $client = $connection->client(); + // no break + case 2: + return $client; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) use ($mode) { + $client = $connection->client(); + + switch ($mode) { + case 1: + return $client; + case ($client = null) ?? 2: + break; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) use ($mode) { + $client = $connection->client(); + + return match ($mode) { + 1, ($client = null) ?? 2 => $client, + default => null, + }; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + return $client; + } finally { + $client = null; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + try { + $client = $connection->client(); + $client->ping(); + $client = null; + } catch (Throwable $exception) { + return $client; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = null; + + try { + if ($shared) { + $client = $connection->client(); + + return null; + } + } finally { + $this->client = $client; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + $client->ping(); + $client = null; + } finally { + $this->client = $client; + } + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = null; + + try { + if ($shared) { + return null; + } + + $client = $connection->client(); + } finally { + $store = function () use ($client) { + $this->client = $client; + }; + $store(); + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + try { + $client->ping(); + } finally { + $client->close(); + } + } catch (Throwable $exception) { + $this->client = $client; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $value = $connection; + + try { + $value = $connection->get('key'); + } catch (LogicException) { + $value = null; + } finally { + $this->client = $value; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $value = $connection; + + try { + throw new LogicException(); + } finally { + $this->client = $value; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $client ??= $connection->client(); + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $client ??= throw new LogicException(); + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($other) { + $client = $connection->client(); + $other?->get(throw new LogicException()); + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $clear = function () use (&$value) { + $value = null; + }; + $value = 'value'; + $clear(); + $value ??= $connection; + + return $value; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + + if ($shared) { + return [$client, ($client = null)]; + } + + return null; + }); + Redis::withConnection(function (RedisConnection $connection) { + static $child = go(fn () => $connection->get('key')); + }); + Redis::withConnection(function (RedisConnection $connection) { + $this->{go(fn () => $connection)}(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $items = []; + [$items[go(fn () => $connection)]] = ['item']; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $items = []; + + foreach ($rows as $items[go(fn () => $connection)]) { + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $items = []; + [$this->client, $items[throw new LogicException()]] = [$connection, 'item']; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = $connection->client(); + $items = []; + + foreach ($rows as $client => $items[go(fn () => $client)]) { + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $items = []; + $source = ['first', 'second']; + [$items[go(fn () => $client)], &$client] = $source; + }); + Redis::withConnection(function (RedisConnection $connection) { + yield $connection->get('key'); + }); + Redis::withConnection(fn (RedisConnection $connection) => yield $connection->get('key')); + DB::withPinnedSession(function () { + yield DB::getPdo(); + }); + } + + public function staysInside(bool $shared, int $mode, array $rows, PdoConnection $database, PdoConnection $replica): void + { + Redis::withConnection(function (RedisConnection $connection) { + foreach (['first', 'second'] as $row) { + $clients = []; + $task = fn () => $clients; + $clients[] = $connection->client(); + } + + return $task; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $clients = []; + + foreach ($rows as $row) { + $clients[] = $connection->client(); + $clients = []; + } + + return $clients; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = null; + $task = function () use (&$client) { + return $client; + }; + + for ($index = 0; $index < 2; ++$index) { + unset($client); + $client = $connection; + $this->callback = $task; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + while (true) { + $client->ping(); + $client = null; + + break; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = $connection->client(); + + do { + $client = null; + } while ($shared); + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = null; + + foreach ($rows as $row) { + $client = $connection->client(); + + try { + break; + } finally { + $client = null; + } + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($shared) { + $client = null; + + foreach (['row'] as $row) { + try { + if ($shared) { + $client = $connection->client(); + + return 'returned'; + } + + break; + } finally { + $row = null; + } + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + foreach (['key'] as $key) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $keys = ['key']; + $client = $connection; + + foreach ($keys as $key) { + $client = null; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($mode) { + $client = $connection->client(); + + switch ($mode) { + default: + $client = null; + + break; + } + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) use ($mode) { + $client = $connection->client(); + + return match ($mode) { + default => $client, + ($client = null) ?? 1 => 1, + }; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = 'none'; + $client ??= $connection->client(); + + return $client; + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + $client = null; + $this->refresh(); + } finally { + $this->client = $client; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + try { + $client->ping(); + } finally { + $client = null; + } + } catch (Throwable $exception) { + $this->client = $client; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + try { + $client->ping(); + } finally { + return null; + } + } catch (Throwable $exception) { + $this->client = $client; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + + try { + $length = strlen(...); + $client = null; + } finally { + $this->client = $client; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $value = $connection; + + try { + $value = $connection->get('key'); + } catch (Throwable) { + $value = null; + } finally { + $this->client = $value; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $value = $connection; + + try { + throw new LogicException(); + } catch (Throwable) { + $value = null; + } finally { + $this->client = $value; + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $items = []; + [$client, $items[go(fn () => $client?->ping())]] = [null, 'item']; + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = $connection->client(); + $items = []; + + foreach ($rows as [$client, $items[go(fn () => $client)]]) { + } + }); + Redis::withConnection(function (RedisConnection $connection) use ($rows) { + $client = $connection->client(); + $items = []; + + foreach ($rows as $items[go(fn () => $client)] => $client) { + } + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $items = []; + unset($client, $items[go(fn () => $client)]); + }); + Redis::withConnection(function (RedisConnection $connection) { + $client = $connection->client(); + $items = []; + $source = ['first', 'second']; + [&$client, $items[go(fn () => $client)]] = $source; + }); + Redis::withConnection(function (RedisConnection $connection) { + yield 'value'; + }); + Redis::withConnection(function (RedisConnection $connection) { + $keys = $connection->keys('*'); + + return (function () use ($keys) { + yield from $keys; + })(); + }); + Redis::withConnection(function (RedisConnection $connection) { + $connection = null; + + yield $connection; + }); + $database->withPinnedSession(function () use ($database, $replica) { + $database = $replica; + + yield $database->getPdo(); + }); + } + + private function refresh(): void + { + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/coroutines-in-transactions.php b/tests/PHPStan/Rules/Coroutine/data/coroutines-in-transactions.php index 625c2e97d..43184b26e 100644 --- a/tests/PHPStan/Rules/Coroutine/data/coroutines-in-transactions.php +++ b/tests/PHPStan/Rules/Coroutine/data/coroutines-in-transactions.php @@ -98,6 +98,13 @@ public function runsSelectedDrivers(ConcurrencyManager $manager, string $name): }); } + public function spawnsThroughNullsafeCall(?WaitConcurrent $waitConcurrent): void + { + DB::transaction(function () use ($waitConcurrent): void { + $waitConcurrent?->fork(fn () => null); + }); + } + /** * @return Closure(): void */ diff --git a/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-catches.php b/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-catches.php index 20f3edb0c..60995b7bf 100644 --- a/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-catches.php +++ b/tests/PHPStan/Rules/Coroutine/data/exact-cancellation-catches.php @@ -143,6 +143,40 @@ public function handlesOnlyOrdinaryFailures(): void } } + public function reportsOrdinaryFailuresAndRethrowsCancellation(): void + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (! Coroutine::causedByCancellation($exception)) { + $this->report($exception); + + return; + } + + throw $exception; + } + } + + public function reportsCancellationBeforeRethrowing(): void + { + try { + $this->work(); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (! Coroutine::causedByCancellation($exception)) { + $this->cleanUp(); + } + + $this->report($exception); + + throw $exception; + } + } + /** * @param list $coroutineIds */ @@ -240,6 +274,19 @@ public function hasNoBroadCatch(): void } } + public function waitsOnAssignedChannel(Channel $held): mixed + { + try { + $channel = $held; + + return $channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + private function work(): void { } @@ -268,3 +315,67 @@ public function pop(float $timeout = -1): mixed return parent::pop($timeout); } } + +class ChannelProperties +{ + public Channel $hooked { + set => $value; + } + + private Channel $channel; + + private Channel $secondary; + + public function waitsOnReplacedProperty(PollingChannel $replacement): mixed + { + $this->channel = new Channel(1); + + try { + $this->channel = $replacement; + + return $this->channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function waitsOnHookedProperty(): mixed + { + try { + $this->hooked = new Channel(1); + + return $this->hooked->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function waitsOnNullableChannel(?Channel $channel): mixed + { + try { + return $channel?->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } + + public function waitsOnPropertyReplacedThroughAnother(PollingChannel $replacement): mixed + { + try { + $this->secondary = $replacement; + $this->channel = $this->secondary; + + return $this->channel->pop(1.0); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable) { + return null; + } + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/raw-coroutines.php b/tests/PHPStan/Rules/Coroutine/data/raw-coroutines.php new file mode 100644 index 000000000..e5b4a88bf --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/raw-coroutines.php @@ -0,0 +1,47 @@ +execute(); + InheritingEngineCoroutine::create(static function (): void {}); + $pending?->execute(); + + Coroutine::create(static function (): void {}); + Coroutine::fork(static function (): void {}); + go(static function (): void {}); + co(static function (): void {}); + run(static function (): void {}); + OverridingEngineCoroutine::create(static function (): void {}); + EngineCoroutine::create(...); + } +} + +class InheritingEngineCoroutine extends EngineCoroutine +{ +} + +class OverridingEngineCoroutine extends EngineCoroutine +{ + public static function create(callable $callable, mixed ...$data): static + { + return new static($callable); + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/restricted-attributes.php b/tests/PHPStan/Rules/Coroutine/data/restricted-attributes.php new file mode 100644 index 000000000..45f3a9086 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/restricted-attributes.php @@ -0,0 +1,47 @@ +apply(); + } + + private function apply(): void + { + Registry::register(); + Config::set('app.name', 'Hypervel'); + Config::macro('settings', static fn (): string => ''); + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/restricted-calls.php b/tests/PHPStan/Rules/Coroutine/data/restricted-calls.php new file mode 100644 index 000000000..e8262f76b --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/restricted-calls.php @@ -0,0 +1,342 @@ +fake(); + RegistryFacade::fake(); + UnknownRegistryFacade::fake(); + $registry->fake(...); + + $registry->configure([]); + $fresh->configure([]); + $this->registry->configure([]); + $this->mutable->configure([]); + $this->fresh->configure([]); + RegistryFacade::configure([]); + app(Registry::class)->configure([]); + app(FreshRegistry::class)->configure([]); + (new Registry)->configure([]); + $local = new Registry; + $local->configure([]); + + $this->conditions(null, []); + } + + public function update(Registry $registry): void + { + $registry = new Registry; + $registry->configure([]); + } + + public function framework(): void + { + (new Factory)->globalOptions([]); + Http::globalOptions([]); + Date::useClass(CarbonImmutable::class); + Config::set('app.name', 'Hypervel'); + Config::macro('restricted', static fn (): string => ''); + } + + private function conditions(?array $map, array $arguments): void + { + $this->registry->map(); + $this->registry->map(null); + $this->registry->map(['a' => 'b']); + $this->registry->map(map: ['a' => 'b']); + $this->registry->map($map); + $this->registry->map(...$arguments); + $this->registry->remember(); + $this->registry->remember(60); + $this->registry->remember(duration: null); + $this->registry->remember(refresh: true); + $this->registry->remember(...$arguments); + } +} + +class ProcessReport implements ShouldQueue +{ + use Dispatchable; + + public function __construct(private readonly Registry $registry) + { + } + + public function handle(Registry $registry): void + { + $registry->configure([]); + $this->registry->configure([]); + } +} + +class ReceiveListener +{ + public function handle(): void + { + Registry::register('operation'); + } +} + +class RegistryServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Registry::register('boot'); + Http::globalOptions([]); + $this->app->make(ProcessReport::class); + + Route::get('/closure', function (Registry $registry): void { + Registry::register('closure'); + $registry->configure([]); + }); + } +} + +class RegistryTest extends TestCase +{ + public function testFake(): void + { + RegistryFacade::fake(); + } +} + +class FactoryRegistry extends Registry +{ +} + +class ContextualRegistry extends Registry +{ +} + +class ReceiverController +{ + public function __construct( + private readonly FactoryRegistry $shared, + private readonly Registry $fresh, + private readonly ContextualRegistry $unknown, + private readonly ConfigRepository $config, + ) { + } + + public function index(): void + { + $this->shared->configure([]); + $this->fresh->configure([]); + $this->unknown->configure([]); + app()->make(Registry::class)->configure([]); + (new Container)->make(Registry::class)->configure([]); + + $this->config->set('app.name', 'Hypervel'); + $this->config['app.locale'] = 'en'; + } + + public function replace(Registry $registry): void + { + self::swap($registry); + $registry->configure([]); + } + + public function show(Registry $registry): void + { + $registry->configure([]); + } + + private static function swap(Registry &$registry): void + { + $registry = new Registry; + } +} + +class ContextualConfigController +{ + public function __construct(private readonly Repository $config) + { + } + + public function update(): void + { + $this->config->set('app.name', 'Hypervel'); + } +} + +class QueuedRegistryListener implements ShouldQueue +{ + public function __construct(private readonly Registry $registry) + { + } + + public function record(): void + { + $this->registry->configure([]); + } +} + +abstract class RegistryJob implements ShouldQueue +{ + use Dispatchable; + + public function handle(Registry $registry): void + { + $registry->configure([]); + } +} + +class FreshRegistryJob extends RegistryJob +{ +} + +class SharedRegistryJob extends RegistryJob +{ +} + +abstract class RegistryTask implements ShouldQueue +{ + use Dispatchable; + + public function handle(Registry $registry): void + { + $registry->configure([]); + } +} + +class SharedRegistryTask extends RegistryTask +{ +} + +class UnknownRegistryTask extends RegistryTask +{ +} + +abstract class RegistryListener implements ShouldQueue +{ + public function __construct(private readonly Registry $registry) + { + } + + public function record(): void + { + $this->registry->configure([]); + $this->refresh(); + } + + public function handle(): void + { + $this->registry->configure([]); + } + + protected function refresh(): void + { + $this->registry->configure([]); + } +} + +class InheritedRegistryListener extends RegistryListener +{ +} + +class SerializedRegistryJob extends RegistryListener +{ + use Dispatchable; +} + +class OptionRegistryListener implements ShouldQueue +{ + public function __construct(private readonly Registry $registry) + { + } + + public function handle(): void + { + $this->registry->configure([]); + } + + public function viaQueue(): string + { + // The dispatcher reads the queue from an instance made without the constructor. + $this->registry ??= new Registry; + $this->registry->configure([]); + + return 'reports'; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/swallowed-cancellation-waits.php b/tests/PHPStan/Rules/Coroutine/data/swallowed-cancellation-waits.php new file mode 100644 index 000000000..5e8dedcdd --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/swallowed-cancellation-waits.php @@ -0,0 +1,55 @@ +wait(1.0); + } catch (Throwable) { + return; + } + + try { + $limiter->create(static function (): void { + }); + } catch (Throwable) { + return; + } + + try { + $results->pop(1.0); + } catch (Throwable) { + return; + } + + $this->connection = $connection; + + try { + $this->connection->select('select 1'); + } catch (Throwable) { + return; + } + + try { + $this->connection = DB::connection(); + $this->connection->select('select 1'); + } catch (Throwable) { + return; + } + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/swallowed-cancellations.php b/tests/PHPStan/Rules/Coroutine/data/swallowed-cancellations.php new file mode 100644 index 000000000..cdaadf722 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/swallowed-cancellations.php @@ -0,0 +1,793 @@ +getPrevious()); + + parent::__construct('Import failed.', 0, $cause); + } +} + +class ConditionalCause extends RuntimeException +{ + public function __construct(Throwable $cause, bool $chain) + { + if ($chain) { + parent::__construct('Import failed.', 0, $cause); + } else { + parent::__construct('Import failed.'); + } + } +} + +class BufferedChannel extends EngineChannel +{ + public function pop(float $timeout = -1): mixed + { + return null; + } +} + +class Import extends Model +{ + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(Item::class); + } + + /** @return BelongsToMany */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class); + } +} + +class Item extends Model +{ +} + +class Tag extends Model +{ +} + +class ArchivedImport extends Model +{ + use SoftDeletes; +} + +class KeptImport extends Model +{ + use SoftDeletes; + + public function restore(): bool + { + return true; + } +} + +class SilentSleep extends Sleep +{ + protected function goodnight(): void + { + } +} + +class ImportJob implements ShouldQueue +{ + /** + * @param EloquentCollection $imports + */ + public function handle( + Channel $channel, + EngineChannel $held, + Container $app, + Import $import, + EloquentCollection $imports, + ArchivedImport $archived, + KeptImport $kept, + MySqlBuilder $schema, + ): void + { + try { + DB::select('select 1'); + } catch (Throwable) { + $fallback = null; + } + + try { + $channel->pop(1.0); + } catch (Throwable $exception) { + return; + } + + try { + Import::query()->count(); + } catch (CanceledException) { + return; + } + + try { + Redis::get('import'); + } catch (Throwable $exception) { + throw new UnchainedFailure('Import failed.', $exception); + } + + try { + usleep(1000); + } catch (Throwable $exception) { + throw new ForeignCancellationException('Import canceled.', previous: $exception); + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + return; + } + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + throw new WrappedFailure('Import failed.', $exception); + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + report($exception); + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if (! Coroutine::causedByCancellation($exception)) { + report($exception); + + return; + } + + throw $exception; + } + + try { + Sleep::usleep(1000); + } catch (Throwable) { + return; + } + + try { + Sleep::for(1)->second()->and(5)->milliseconds(); + } catch (Throwable) { + return; + } + + try { + Sleep::for(1)->second()->then(static fn (): null => null); + } catch (Throwable) { + return; + } + + $sleep = Sleep::for(1); + + try { + $sleep->second(); + } catch (Throwable) { + return; + } + + try { + $value = Sleep::for(1)->second()->then(static fn (): int => 1); + } catch (Throwable) { + return; + } + + try { + SilentSleep::for(1)->second(); + SilentSleep::for(1)->second()->then(static fn (): null => null); + } catch (Throwable) { + return; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + foreach ([1, 2] as $index) { + switch ($index) { + case 1: + continue 2; + default: + break; + } + } + + throw $exception; + } + + foreach ([1, 2] as $attempt) { + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if ($attempt === 1) { + continue; + } + + throw $exception; + } + } + + try { + $query = Import::query(); + $query->where('status', 'pending')->get(); + } catch (Throwable) { + return; + } + + try { + $queue = new Channel(1); + $queue->pop(1.0); + } catch (Throwable) { + return; + } + + try { + $channel = new BufferedChannel(1); + $channel->pop(1.0); + } catch (Throwable) { + return; + } + + try { + $held = new BufferedChannel(1); + $alias = $held; + $alias->pop(1.0); + } catch (Throwable) { + return; + } + + try { + $query = Import::query(); + $tenant = 'acme'; + $query->where('tenant', $tenant)->get(); + } catch (Throwable) { + return; + } + + try { + $ids = [1, 2]; + Import::whereIn('id', $ids)->update(['status' => 'done']); + } catch (Throwable) { + return; + } + + try { + $model = Import::class; + $model::create(['status' => 'pending']); + } catch (Throwable) { + return; + } + + $class = EngineChannel::class; + + try { + $class = BufferedChannel::class; + $app->make($class)->pop(1.0); + } catch (Throwable) { + return; + } + + $class = EngineChannel::class; + + try { + $class = BufferedChannel::class; + $buffer = app($class); + $buffer->pop(1.0); + } catch (Throwable) { + return; + } + + try { + DB::table('imports')->truncate(); + } catch (Throwable) { + return; + } + + try { + $import->increment('attempts'); + } catch (Throwable) { + return; + } + + try { + $import->load('items'); + } catch (Throwable) { + return; + } + + try { + $import->items()->get(); + } catch (Throwable) { + return; + } + + try { + $import->items()->count(); + } catch (Throwable) { + return; + } + + try { + $import->tags()->attach(1); + } catch (Throwable) { + return; + } + + try { + $archived->restore(); + } catch (Throwable) { + return; + } + + try { + ArchivedImport::onlyTrashed()->restore(); + } catch (Throwable) { + return; + } + + try { + $imports->load('items'); + } catch (Throwable) { + return; + } + + try { + Schema::hasTable('imports'); + } catch (Throwable) { + return; + } + + try { + $schema->dropAllTables(); + } catch (Throwable) { + return; + } + + try { + $kept->restore(); + $schema->dropAllTypes(); + $imports->find(1); + $imports->toQuery(); + $import->items()->lazy(); + } catch (Throwable) { + return; + } + + try { + try { + usleep(1000); + } catch (Throwable) { + return; + } + } catch (Throwable) { + return; + } + + try { + try { + usleep(1000); + } catch (Throwable $exception) { + throw $exception; + } + } catch (Throwable) { + return; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + $exception = new RuntimeException('Import failed.'); + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + $exception = new WrappedFailure('Import failed.', $exception); + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + throw new ReplacedCause($exception); + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + throw new ConditionalCause($exception, true); + } + + try { + DB::table('imports')->cursor(); + Redis::pipeline(); + Redis::connection('cache'); + } catch (Throwable) { + return; + } + + try { + DB::select('select 1'); + } catch (CanceledException $exception) { + $cancellation = $exception; + } catch (Throwable) { + return; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if ($import->exists) { + throw new RuntimeException('Import failed.'); + } + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw new RuntimeException('Import canceled.'); + } + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if ($import->exists) { + throw new WrappedFailure('Import failed.', $exception); + } + + try { + throw new RuntimeException('Cleanup failed.'); + } catch (RuntimeException) { + $cleanupFailed = true; + } + + throw $exception; + } + + $connection = $import->exists ? DB::connection() : null; + + try { + $connection ??= DB::connection('replica'); + $connection->select('select 1'); + } catch (Throwable) { + return; + } + } +} + +class ImportService +{ + public function run(): void + { + try { + DB::select('select 1'); + } catch (Throwable) { + return; + } + } +} + +class SyncImportJob implements ShouldQueue +{ + /** + * @param list $rows + */ + public function handle(BufferedChannel $buffer, EngineChannel $channel, Import $import, array $rows): void + { + try { + Redis::connection('cache')->pipeline(function (): void { + }); + } catch (Throwable) { + return; + } + + try { + foreach (Import::lazy() as $lazyImport) { + $rows[] = $lazyImport; + } + } catch (Throwable) { + return; + } + + try { + DB::table('imports')->cursor()->each(function (object $row): void { + }); + } catch (Throwable) { + return; + } + + try { + foreach (DB::connection()->cursor('select 1') as $row) { + $rows[] = $row; + } + } catch (Throwable) { + return; + } + + try { + foreach ($import->tags()->lazy() as $tag) { + $rows[] = $tag; + } + } catch (Throwable) { + return; + } + + try { + $buffer ??= new EngineChannel(1); + $buffer->pop(1.0); + } catch (Throwable) { + return; + } + + try { + $lazy = Import::lazy(); + + foreach ($lazy as $lazyImport) { + $rows[] = $lazyImport; + } + } catch (Throwable) { + return; + } + + try { + Import::lazy()->map(fn (Import $lazyImport): int => 1)->each(function (int $value): void { + }); + } catch (Throwable) { + return; + } + + try { + $consume = Import::lazy()->each(...); + } catch (Throwable) { + return; + } + + try { + Import::lazy()?->each(function (Import $lazyImport): void { + }); + } catch (Throwable) { + return; + } + + try { + $class = BufferedChannel::class; + $channel ??= new $class(1); + $channel->pop(1.0); + } catch (Throwable) { + return; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + foreach ([new RuntimeException('Import failed.')] as $exception) { + report($exception); + } + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + throw new DestructuredCause($exception); + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + throw new RepeatedCause($exception, 'Import failed.', $exception); + } + + try { + Sleep::for(1)?->second(); + } catch (Throwable) { + return; + } + + try { + QuietSleep::for(1)->second(); + QuietSleep::for(1)->then(static fn (): null => null); + } catch (Throwable) { + return; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + report($exception); + + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + return; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + report($exception); + + if (! Coroutine::causedByCancellation($exception)) { + return; + } + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if ($import->exists) { + return; + } + + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + $exception = new RuntimeException('Import failed.'); + + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + } + + try { + try { + DB::select('select 1'); + } catch (Throwable $exception) { + report($exception); + + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + return; + } + } catch (Throwable) { + return; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + report($exception); + } else { + return; + } + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if (! Coroutine::causedByCancellation($exception)) { + return; + } else { + report($exception); + } + + throw $exception; + } + + try { + DB::select('select 1'); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + report($exception); + } + + return; + } + } +} + +class DestructuredCause extends RuntimeException +{ + public function __construct(Throwable $previous) + { + [$previous] = [new RuntimeException('Hidden.')]; + + parent::__construct('Import failed.', 0, $previous); + } +} + +class RepeatedCause extends RuntimeException +{ + public function __construct(Throwable $cause, string $message, ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + } +} + +class QuietSleep extends Sleep +{ + public function __construct(DateInterval|float|int $duration) + { + parent::__construct($duration); + + $this->shouldSleep = false; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/swoole-tables.php b/tests/PHPStan/Rules/Coroutine/data/swoole-tables.php new file mode 100644 index 000000000..f592c3617 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/swoole-tables.php @@ -0,0 +1,40 @@ +app->singleton('counters', static function (): Table { + return new Table(1024); + }); + $this->app->singleton('limits', static fn (): Table => new Table(1024)); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $counters = new CounterTable(1024); + } + } +} + +class TableRegistrar +{ + public function register(): void + { + $table = new Table(1024); + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state-globals-account.php b/tests/PHPStan/Rules/Coroutine/data/worker-state-globals-account.php new file mode 100644 index 000000000..be2f616f1 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state-globals-account.php @@ -0,0 +1,14 @@ +user(); + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state-globals-request.php b/tests/PHPStan/Rules/Coroutine/data/worker-state-globals-request.php new file mode 100644 index 000000000..056bca236 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state-globals-request.php @@ -0,0 +1,14 @@ +remember(); + $this->update($request); + } + + private function remember(): void + { + global $tenant; + $tenant = 'acme'; + } + + private function update(Request $request): void + { + global $settings, $profile, $visits, $previous, $position, $current, $listed, $alias, $rebound, $failure; + unset($settings['theme']); + unset($profile->locale); + $settings['request'] = &$request; + $visits += 1; + $GLOBALS['visits'] .= '!'; + [$previous] = [$request]; + [$GLOBALS['first']] = [$request]; + $requests = [$request]; + + foreach ($requests as $position => $current) { + } + + foreach ([[$request]] as [$listed]) { + } + + foreach ($requests as &$GLOBALS['loop']) { + } + + foreach ($requests as &$alias) { + } + + $alias = $request; + [&$rebound] = $requests; + $rebound = $request; + [&$settings['reference']] = $requests; + + try { + $this->remember(); + } catch (RuntimeException $failure) { + } + + global $matches; + preg_match('/\d+/', $request->path(), $matches); + sort($GLOBALS['list']); + count($GLOBALS['list']); + count($matches); + $match = preg_match(...); + $this->matcher?->fill($matches); + $this->matcher?->fill($GLOBALS['filled']); + sort($profile->tags); + + if (! function_exists(__NAMESPACE__ . '\\countLocally')) { + function countLocally(): void + { + $visits = 1; + } + } + + unset($settings); + $settings = $request; + } +} + +class GlobalsService +{ + public function keep(Request $request): void + { + global $held; + $held['request'] = $request; + $GLOBALS['request'] = $request; + $local = $request; + [$held] = [$request]; + $nested = function () use ($request): void { + $held = $request; + }; + } + + public function detach(Request $request, bool $shared): void + { + global $held, $other; + unset($held); + $held = $request; + $other = &$request; + $other = $request; + + if ($shared) { + global $conditional; + } + + $conditional = $request; + } + + public function aliases(Request $request): void + { + $forward = &$GLOBALS['forward']; + $forward = $request; + $GLOBALS['reverse'] = &$reverse; + $reverse = $request; + global $registry; + $registry['item'] = &$item; + $item = $request; + $first = &$GLOBALS['chained']; + $second = &$first; + $second = $request; + global $pointed; + $pointed = &$local; + $local = $request; + $gone = &$GLOBALS['gone']; + unset($gone); + $gone = $request; + $moved = &$GLOBALS['moved']; + $moved = &$elsewhere; + $moved = $request; + global $memo; + $memo ??= $request; + $GLOBALS['memo'] ??= $request; + global $holder; + $property = &$holder->value; + $property = $request; + } + + public function beforeDeclaration(Request $request): void + { + $held = $request; + global $held; + } +} + +class GlobalsTraits +{ + use KeepsRequest; + use KeepsAccount; +} + +class GlobalsServiceProvider extends ServiceProvider +{ + public function boot(): void + { + $GLOBALS['booted'] = true; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state-listener-options.php b/tests/PHPStan/Rules/Coroutine/data/worker-state-listener-options.php new file mode 100644 index 000000000..be8638548 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state-listener-options.php @@ -0,0 +1,231 @@ + */ + public array $queues = []; + + /** + * Handle the event. + */ + public function handle(RequestHandled $event): void + { + } + + /** + * Determine whether the listener should be queued, on the container's instance. + */ + public function shouldQueue(RequestHandled $event): bool + { + $this->remember(); + + return true; + } + + /** + * Get the queue, on the instance the dispatcher makes without the constructor. + */ + public function viaQueue(RequestHandled $event): string + { + self::$queued = true; + $this->queue = 'reports'; + $this->request = request(); + $this->queues[request()->ip()] = 'reports'; + $this->remember(); + + return $this->queue; + } + + /** + * Get the job's deduplication ID. + */ + public function deduplicationId(string $payload, string $queue): string + { + self::$queued = true; + + return $queue; + } + + /** + * Get the job's unique ID, which the dispatcher reads only from unique listeners. + */ + public function uniqueId(): string + { + self::$queued = true; + + return 'reports'; + } + + /** + * Remember the current request. + */ + private function remember(): void + { + $this->request = request(); + } +} + +class BoundOptionListener implements ShouldQueue +{ + public ?Request $request = null; + + /** + * Handle the event. + */ + public function handle(RequestHandled $event): void + { + } + + /** + * Get the queue, on the instance of the registered class the dispatcher makes. + */ + public function viaQueue(RequestHandled $event): string + { + $this->request = request(); + + return 'reports'; + } +} + +class OverridingOptionListener extends BoundOptionListener +{ + /** + * Get the queue, which the dispatcher never calls on the bound subclass. + */ + public function viaQueue(RequestHandled $event): string + { + $this->request = request(); + + return 'reports'; + } +} + +class UniqueOptionListener implements ShouldQueue, ShouldBeUnique +{ + public static bool $locked = false; + + /** + * Handle the event. + */ + public function handle(RequestHandled $event): void + { + } + + /** + * Get the job's unique ID. + */ + public function uniqueId(): string + { + self::$locked = true; + + return 'reports'; + } + + /** + * Get the job's deduplicator, which the dispatcher reads instead of its deduplication ID. + */ + public function deduplicator(): Closure + { + self::$locked = true; + + return static fn (string $payload): string => $payload; + } + + /** + * Get the job's deduplication ID. + */ + public function deduplicationId(string $payload, string $queue): string + { + self::$locked = true; + + return $queue; + } +} + +#[DebounceFor(10)] +class DebouncedListener implements ShouldQueue +{ + public static bool $debounced = false; + + /** + * Handle the event. + */ + public function handle(RequestHandled $event): void + { + } + + /** + * Get the job's debounce ID. + */ + public function debounceId(): string + { + self::$debounced = true; + + return 'reports'; + } +} + +class DebouncedByDefaultListener implements ShouldQueue +{ + public static bool $debounced = false; + + public ?int $debounceFor = 0; + + /** + * Handle the event. + */ + public function handle(RequestHandled $event): void + { + } + + /** + * Get the job's debounce ID. + */ + public function debounceId(): string + { + self::$debounced = true; + + return 'reports'; + } +} + +class UndebouncedListener implements ShouldQueue +{ + public static bool $debounced = false; + + public ?int $debounceFor = null; + + /** + * Handle the event. + */ + public function handle(RequestHandled $event): void + { + } + + /** + * Get the job's debounce ID, which the dispatcher reads only from debounced listeners. + */ + public function debounceId(): string + { + self::$debounced = true; + + return 'reports'; + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state-listeners.php b/tests/PHPStan/Rules/Coroutine/data/worker-state-listeners.php new file mode 100644 index 000000000..ae8de7685 --- /dev/null +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state-listeners.php @@ -0,0 +1,397 @@ +audit(); + } + + /** + * Audit the change. + */ + public function audit(): void + { + static::$audited = true; + } + + /** + * Audit nothing; no event calls it. + */ + public function unused(): void + { + static::$audited = true; + } +} + +class RepeatedObserver +{ + public static bool $updated = false; + + /** + * Handle the model's update. + */ + public function updated(): void + { + static::$updated = true; + } +} + +#[ObservedBy(AuditObserver::class)] +abstract class AuditedModel extends Model +{ +} + +class Invoice extends AuditedModel +{ + protected array $observables = ['archived']; +} + +class BaseObserver +{ + public static bool $deleted = false; + + /** + * Handle the model's deletion. + */ + public function deleted(): void + { + static::$deleted = true; + } +} + +class AuditObserver extends BaseObserver +{ + public static bool $archived = false; + + /** + * Handle the model's custom archived event. + */ + public function archived(): void + { + static::$archived = true; + } +} + +#[ObservedBy(CustomEventsObserver::class)] +class CustomEventsModel extends Model +{ + /** + * Get the observable event names. + */ + public function getObservableEvents(): array + { + return ['created']; + } +} + +class CustomEventsObserver +{ + public static bool $created = false; + + /** + * Handle the model's creation. + */ + public function created(): void + { + static::$created = true; + } +} + +class WildcardJobListener +{ + public static bool $seen = false; + + /** + * Handle any job event. + */ + public function handle(): void + { + static::$seen = true; + } +} + +class CatchAllObserver +{ + public static bool $observed = false; + + /** + * Observe any event. + */ + public function observe(): void + { + static::$observed = true; + } +} + +class QueuedObserver implements ShouldQueue +{ + public static bool $observed = false; + + public function __construct(private readonly Repository $config) + { + } + + /** + * Observe the request, which runs synchronously even though the class is queueable. + */ + public function handle(): void + { + static::$observed = true; + $this->config->set('app.observed', true); + } +} + +class ClonedEvent +{ + public function __construct(public Request $request) + { + } +} + +class ClonedListener implements ShouldQueue +{ + use InteractsWithQueue; + + public ?Request $request = null; + + public ?Request $latest = null; + + /** @var array */ + public array $seen = []; + + public int $tracked = 0; + + public int $failures = 0; + + public string $status = 'idle'; + + /** @var null|array */ + public ?array $counts = null; + + /** + * Decide on the container's instance whether to queue the event. + */ + public function shouldQueue(ClonedEvent $event): bool + { + $this->remember($event); + + return true; + } + + /** + * Handle the event on the job's clone of the listener. + */ + public function handle(ClonedEvent $event): void + { + $this->request = $event->request; + $this->seen[$event->request->ip()] = true; + $this->status[0] = 'b'; + $this->counts['handled'] = 1; + $this->remember($event); + $this->track(); + } + + /** + * Handle the failure on the container's instance. + */ + public function failed(): void + { + ++$this->failures; + } + + /** + * Remember the event's request. + */ + private function remember(ClonedEvent $event): void + { + $this->latest = $event->request; + } + + /** + * Track the event. + */ + private function track(): void + { + ++$this->tracked; + } +} + +class CollectingListener implements ShouldQueue +{ + use InteractsWithQueue; + + /** @var Collection */ + public Collection $values; + + /** @var array> */ + public array $groups = []; + + /** @var array|Collection */ + public array|Collection $totals = []; + + /** + * Handle the event on the job's clone, whose properties still hold the container instance's objects. + */ + public function handle(ClonedEvent $event): void + { + $this->values['request'] = $event->request; + $this->groups['imports']['count'] = 1; + $this->totals['count'] = 1; + $this->count(); + } + + /** + * Count the event. + */ + private function count(): void + { + $this->values['count'] = 1; + } +} + +class Registry +{ + public ?Request $request = null; + + /** + * Remember the request. + */ + public function remember(Request $request): void + { + $this->request = $request; + } +} + +class RegistryListener extends Registry implements ShouldQueue +{ + use InteractsWithQueue; + + /** + * Handle the event on the job's clone. + */ + public function handle(ClonedEvent $event): void + { + $this->remember($event->request); + } +} + +abstract class BaseRegistry +{ + public ?Request $request = null; + + /** + * Keep the request. + */ + public function keep(Request $request): void + { + $this->request = $request; + } +} + +class BaseRegistryListener extends BaseRegistry implements ShouldQueue +{ + use InteractsWithQueue; + + /** + * Handle the event on the job's clone. + */ + public function handle(ClonedEvent $event): void + { + $this->keep($event->request); + } +} + +class SharedRegistry extends BaseRegistry +{ +} + +class ObservingClonedListener implements ShouldQueue +{ + use InteractsWithQueue; + + public ?Request $request = null; + + /** + * Handle the event on the job's clone, and on the container's instance as a passive observer. + */ + public function handle(): void + { + $this->request = request(); + } +} diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state-routes.php b/tests/PHPStan/Rules/Coroutine/data/worker-state-routes.php index b5a622ff4..3578c3cbf 100644 --- a/tests/PHPStan/Rules/Coroutine/data/worker-state-routes.php +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state-routes.php @@ -58,3 +58,9 @@ function registerRoutes(Router $router): void collect([1])->each(function (): void { CountingMiddleware::$seen = true; }); + +Route::get('/locale', fn (): string => config()['app.locale'] = 'fr'); + +Route::get('/nested-locale', function (): void { + collect([1])->each(fn (): string => config()['app.locale'] = 'fr'); +}); diff --git a/tests/PHPStan/Rules/Coroutine/data/worker-state.php b/tests/PHPStan/Rules/Coroutine/data/worker-state.php index 303735868..96bd9d411 100644 --- a/tests/PHPStan/Rules/Coroutine/data/worker-state.php +++ b/tests/PHPStan/Rules/Coroutine/data/worker-state.php @@ -872,3 +872,45 @@ public function handle(): void app('config', ['option' => true])->set('app.named', true); } } + +// Offset writes inside other expressions change configuration too. +class NestedConfigOffsetJob implements ShouldQueue +{ + use Dispatchable; + + public function handle(ConfigRepository $config): string + { + $locale = config()['app.locale'] = 'fr'; + $this->report($config['app.name'] = 'Hypervel'); + + if ($config['app.debug'] = false) { + config()['app.nested'] = true; + } + + return $config['app.timezone'] = $locale; + } + + private function report(string $name): void + { + } +} + +// Only a promoted repository property shows the global repository, so these offset writes are ordinary property writes. +class RepositoryPropertyController +{ + private static ConfigRepository $defaults; + + private ConfigRepository $settings; + + public function __construct(ConfigRepository $config) + { + self::$defaults = $config; + $this->settings = $config; + } + + public function update(): void + { + self::$defaults['app.locale'] = 'fr'; + $this->settings['app.locale'] = 'fr'; + } +} diff --git a/tests/PHPStan/Rules/DiscardedDateRuleTest.php b/tests/PHPStan/Rules/DiscardedDateRuleTest.php new file mode 100644 index 000000000..14857e0b3 --- /dev/null +++ b/tests/PHPStan/Rules/DiscardedDateRuleTest.php @@ -0,0 +1,72 @@ + */ +class DiscardedDateRuleTest extends RuleTestCase +{ + private const string TIP = "Immutable date methods return a new date, so assign the result where it's needed."; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(DiscardedDateRule::class); + } + + public function testRule(): void + { + $unchanged = static fn (string $method, int $line): array => [ + 'The result of ' . $method . '() is discarded, so the immutable date is unchanged.', + $line, + self::TIP, + ]; + $mayChange = static fn (string $method, int $line): array => [ + 'The result of ' . $method . '() is discarded, so any change it makes to an immutable date is lost.', + $line, + self::TIP, + ]; + + $this->analyse([__DIR__ . '/data/discarded-dates.php'], [ + $unchanged('addDay', 28), + $unchanged('subMonths', 29), + $unchanged('startOfDay', 30), + $unchanged('endOfMonth', 31), + $unchanged('next', 32), + $unchanged('previousWeekday', 33), + $unchanged('nthOfMonth', 34), + $unchanged('firstOfQuarter', 35), + $unchanged('lastOfYear', 36), + $unchanged('roundHour', 37), + $unchanged('floorDay', 38), + $unchanged('ceilMinute', 39), + $unchanged('modify', 40), + $unchanged('setTimezone', 41), + $unchanged('year', 42), + $unchanged('setMicroseconds', 43), + $unchanged('addDays', 44), + $unchanged('plus', 45), + $unchanged('utc', 46), + $unchanged('startOfDay', 47), + $unchanged('dayOfYear', 56), + $unchanged('dayOfYear', 57), + $unchanged('dayOfWeek', 58), + $unchanged('tz', 59), + $mayChange('dayOfYear', 60), + $mayChange('week', 61), + $mayChange('addDay', 70), + $mayChange('dayOfYear', 71), + $mayChange('setTime', 72), + $unchanged('subDay', 86), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/ManagedTransactionRuleTest.php b/tests/PHPStan/Rules/ManagedTransactionRuleTest.php new file mode 100644 index 000000000..b987396b9 --- /dev/null +++ b/tests/PHPStan/Rules/ManagedTransactionRuleTest.php @@ -0,0 +1,60 @@ + */ +class ManagedTransactionRuleTest extends RuleTestCase +{ + private const string TIP = 'Let transaction() commit when the callback returns, or throw an exception to roll it back.'; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(ManagedTransactionRule::class); + } + + public function testRule(): void + { + $endedEarly = static fn (string $method, int $line): array => [ + $method . '() ends the transaction that transaction() manages before its callback returns.', + $line, + self::TIP, + ]; + $rolledBackToZero = static fn (int $line): array => [ + 'rollBack(0) rolls back the transaction that transaction() manages, with every enclosing one.', + $line, + self::TIP, + ]; + $generator = static fn (int $line): array => [ + 'The transaction() callback is a generator, so none of its body runs until after transaction() commits.', + $line, + 'Run the database work in the callback itself, and return the results instead of yielding them.', + ]; + + $this->analyse([__DIR__ . '/data/managed-transactions.php'], [ + $endedEarly('commit', 62), + $endedEarly('rollBack', 64), + $endedEarly('rollBack', 66), + $endedEarly('commit', 69), + $endedEarly('commit', 74), + $endedEarly('commit', 78), + $rolledBackToZero(86), + $rolledBackToZero(89), + $endedEarly('commit', 146), + $endedEarly('commit', 165), + $endedEarly('rollBack', 167), + $generator(197), + $generator(200), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/MigrationColumnTypeRuleTest.php b/tests/PHPStan/Rules/MigrationColumnTypeRuleTest.php new file mode 100644 index 000000000..2f513222c --- /dev/null +++ b/tests/PHPStan/Rules/MigrationColumnTypeRuleTest.php @@ -0,0 +1,32 @@ + */ +class MigrationColumnTypeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(MigrationColumnTypeRule::class); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/migration-column-types.php'], [ + ['enum() creates a database enum, so adding a value needs a migration.', 25, 'Use a string column with a PHP enum cast.'], + ['set() creates a database set, so adding a value needs a migration.', 26, 'Store the values in a jsonb() column cast with AsEnumCollection, or in a related table.'], + ["json() creates a json column, which PostgreSQL can't index or compare like jsonb.", 27, 'Use jsonb() for JSON the database queries, indexes or validates, or text() for a value only read back whole. jsonb() uses the appropriate type for each database.'], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/migration-column-types.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php b/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php index 8f7d98602..fdbf02477 100644 --- a/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php +++ b/tests/PHPStan/Rules/NoEnvCallsOutsideOfConfigRuleTest.php @@ -30,13 +30,19 @@ class NoEnvCallsOutsideOfConfigRuleTest extends RuleTestCase protected function getRule(): Rule { + $container = self::getContainer(); + return new NoEnvCallsOutsideOfConfigRule( - self::getContainer()->getByType(ConfigSourceResolver::class), - $this->getFileHelper(), + new ConfigSourceResolver( + $this->getFileHelper(), + $container->getService('currentPhpVersionSimpleDirectParser'), + $this->createReflectionProvider(), + $container->getParameter('configDirectories'), + $this->singleReflectionFile, + $this->singleReflectionInsteadOfFile, + ), $this->createReflectionProvider(), - self::getContainer()->getByType(RuntimeInputs::class), - $this->singleReflectionFile, - $this->singleReflectionInsteadOfFile, + $container->getByType(RuntimeInputs::class), ); } diff --git a/tests/PHPStan/Rules/NoMutableDatesRuleTest.php b/tests/PHPStan/Rules/NoMutableDatesRuleTest.php new file mode 100644 index 000000000..eda37d18c --- /dev/null +++ b/tests/PHPStan/Rules/NoMutableDatesRuleTest.php @@ -0,0 +1,41 @@ + */ +class NoMutableDatesRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoMutableDatesRule::class); + } + + public function testRule(): void + { + $error = static fn (string $subject, int $line): array => [ + $subject . ' creates a mutable date.', + $line, + 'Create dates with the Date facade, the now() and today() helpers, or CarbonImmutable.', + ]; + + $this->analyse([__DIR__ . '/data/mutable-dates.php'], [ + $error('new Hypervel\Support\Carbon', 25), + $error('new Carbon\Carbon', 26), + $error('Hypervel\Support\Carbon::now()', 27), + $error('Carbon\Carbon::parse()', 28), + $error('Hypervel\Support\Carbon::createFromFormat()', 29), + $error('Hypervel\Support\Carbon::make()', 30), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/NoNativeRequestApisRuleTest.php b/tests/PHPStan/Rules/NoNativeRequestApisRuleTest.php new file mode 100644 index 000000000..2959d2c0b --- /dev/null +++ b/tests/PHPStan/Rules/NoNativeRequestApisRuleTest.php @@ -0,0 +1,63 @@ + */ +class NoNativeRequestApisRuleTest extends RuleTestCase +{ + private const string RESPONSE_TIP = "Set headers, cookies and the status on the response with its header(), cookie() and setStatusCode() methods, and read them from the response's headers."; + + private const string SESSION_TIP = 'Use the session store through $request->session(), session() or the Session facade.'; + + private const string INPUT_TIP = 'Read input from the request with its query(), input(), post(), cookie() and file() methods.'; + + private const string HEADER_TIP = "Read the request's headers with its header() method."; + + private const string FILTER_TIP = 'Read the value from the request with its query(), input(), post() or cookie() methods, then validate or filter that value.'; + + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoNativeRequestApisRule::class); + } + + public function testRule(): void + { + $function = static fn (string $name, int $line, string $tip): array => [ + $name . "() does not act on Hypervel's request, response or session.", + $line, + $tip, + ]; + $input = static fn (string $name, int $line): array => [ + '$' . $name . ' does not hold the request Hypervel is handling.', + $line, + self::INPUT_TIP, + ]; + + $this->analyse([__DIR__ . '/data/native-request-apis.php'], [ + $function('header', 15, self::RESPONSE_TIP), + $function('http_response_code', 16, self::RESPONSE_TIP), + $function('session_start', 17, self::SESSION_TIP), + $function('connection_aborted', 21, 'Stream through a generator callback passed to response()->stream() or an IterableStreamedResponse, which stops at the first failed write after the client disconnects. Call cancelOnDisconnect() to also stop it during a wait when the connection closes.'), + $input('_POST', 25), + ["\$_SESSION is not Hypervel's session.", 26, self::SESSION_TIP . ' $_SESSION is shared by every request the worker process handles.'], + $input('_GET', 28), + $function('getallheaders', 36, self::HEADER_TIP), + $function('apache_request_headers', 37, self::HEADER_TIP), + $function('filter_input', 38, self::FILTER_TIP), + $function('filter_input_array', 39, self::FILTER_TIP), + $function('filter_has_var', 40, self::FILTER_TIP), + $function('filter_has_var', 41, self::FILTER_TIP), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/ObserverAttributeRuleTest.php b/tests/PHPStan/Rules/ObserverAttributeRuleTest.php new file mode 100644 index 000000000..9360113f7 --- /dev/null +++ b/tests/PHPStan/Rules/ObserverAttributeRuleTest.php @@ -0,0 +1,51 @@ + */ +class ObserverAttributeRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(ObserverAttributeRule::class); + } + + public function testRule(): void + { + $error = static fn (string $model, int $line): array => [ + 'Hypervel\Tests\PHPStan\Rules\ObserverAttributes\\' . $model . ' registers its observers with observe() in a service provider.', + $line, + 'By convention, list them in #[ObservedBy] on the model. Subclasses inherit attribute observers, so keep observe() with an ignore when they are meant for this class only.', + ]; + + // The loader's installed packages are dependencies, whose models the application cannot change. + $dependencies = new ClassLoader(__DIR__ . '/data/observer-vendor'); + $dependencies->register(); + + try { + $this->analyse([ + __DIR__ . '/data/observer-attributes.php', + __DIR__ . '/data/observer-vendor/acme/AuditEntry.php', + __DIR__ . '/data/observer-packages/ledger/LedgerEntry.php', + ], [ + $error('Invoice', 41), + $error('Payment', 46), + $error('Payment', 47), + ]); + } finally { + $dependencies->unregister(); + } + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/RequestDependentBindingRuleTest.php b/tests/PHPStan/Rules/RequestDependentBindingRuleTest.php new file mode 100644 index 000000000..1fd16be74 --- /dev/null +++ b/tests/PHPStan/Rules/RequestDependentBindingRuleTest.php @@ -0,0 +1,52 @@ + */ +class RequestDependentBindingRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(RequestDependentBindingRule::class); + } + + public function testRule(): void + { + $error = static fn (string $interface, int $line): array => [ + "The #[BindWhen] condition on Hypervel\\Tests\\PHPStan\\Rules\\RequestDependentBindings\\{$interface} contains a read of the current request or user, but a matched condition binds for the worker's lifetime.", + $line, + 'Conditions must depend only on state set while the application boots. Register a scoped() binding to choose an implementation per request.', + ]; + + $this->analyse([__DIR__ . '/data/request-dependent-bindings.php', __DIR__ . '/data/request-dependent-bindings-local-request.php'], [ + $error('UsesRequestHelper', 20), + $error('UsesRequestFacade', 25), + $error('UsesRequestContext', 30), + $error('UsesAuthFacade', 38), + $error('UsesGuard', 44), + $error('ResolvesRequestAlias', 49), + $error('ResolvesRequestByName', 54), + $error('UsesConditionMethod', 59), + $error('UsesConditionFunction', 64), + $error('ReadsInCallback', 108), + $error('CapturesContainer', 121), + $error('NamesRequestContextRead', 145), + $error('NamesAuthRead', 150), + $error('UsesConditionConstant', 160), + $error('SelectsWithOwnMethod', 211), + $error('SelectsWithParentMethod', 220), + $error('LocalRequest\UsesHelperFromAnotherNamespace', 12), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/SocialitePropertyRuleTest.php b/tests/PHPStan/Rules/SocialitePropertyRuleTest.php new file mode 100644 index 000000000..7b63ea417 --- /dev/null +++ b/tests/PHPStan/Rules/SocialitePropertyRuleTest.php @@ -0,0 +1,39 @@ + */ +class SocialitePropertyRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(SocialitePropertyRule::class); + } + + public function testRule(): void + { + $error = static fn (string $property, string $getter, string $setter, int $line): array => [ + "\$this->{$property} holds the provider's default for every request, not the current request's value.", + $line, + "Read it with {$getter}() and set it with {$setter}(), which use the provider's coroutine context.", + ]; + + $this->analyse([__DIR__ . '/data/socialite-properties.php'], [ + $error('clientSecret', 'getClientSecret', 'setConfig', 26), + $error('scopes', 'getScopes', 'setScopes', 38), + $error('parameters', 'getParameters', 'with', 43), + $error('popup', 'getPopup', 'asPopup', 61), + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/TypedConfigGetterRuleTest.php b/tests/PHPStan/Rules/TypedConfigGetterRuleTest.php new file mode 100644 index 000000000..8be275aaa --- /dev/null +++ b/tests/PHPStan/Rules/TypedConfigGetterRuleTest.php @@ -0,0 +1,70 @@ + */ +class TypedConfigGetterRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(TypedConfigGetterRule::class); + } + + public function testApplication(): void + { + $error = static fn (string $key, string $description, string $getter, int $line): array => [ + sprintf('Config key %s always holds %s, so read it with %s().', $key, $description, $getter), + $line, + sprintf("Use config()->%1\$s(), Config::%1\$s() or the repository's %1\$s(), which return the value's type and throw if the configuration changes it.", $getter), + ]; + + // The analyzer tests boot a Testbench package application. + $packageApplication = self::setPackageApplication(false); + + try { + $this->analyse([__DIR__ . '/data/typed-config-getters.php'], [ + $error("'typed.name'", 'a string', 'string', 15), + $error("'typed.port'", 'an integer', 'integer', 16), + $error("'typed.ratio'", 'a float', 'float', 17), + $error("'typed.debug'", 'a boolean', 'boolean', 18), + $error("'typed.hosts'", 'an array', 'array', 19), + $error("'typed.label'|'typed.name'", 'a string', 'string', 20), + ]); + } finally { + self::setPackageApplication($packageApplication); + } + } + + public function testPackageApplication(): void + { + $this->analyse([__DIR__ . '/data/typed-config-getters.php'], []); + } + + /** + * Set whether the booted application is a package's, returning the previous value. + */ + private static function setPackageApplication(bool $packageApplication): bool + { + return Closure::bind(static function () use ($packageApplication): bool { + $previous = RuntimeInputs::$packageApplication; + RuntimeInputs::$packageApplication = $packageApplication; + + return $previous; + }, null, RuntimeInputs::class)(); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } +} diff --git a/tests/PHPStan/Rules/UncastEnvValueRuleTest.php b/tests/PHPStan/Rules/UncastEnvValueRuleTest.php new file mode 100644 index 000000000..4020200f8 --- /dev/null +++ b/tests/PHPStan/Rules/UncastEnvValueRuleTest.php @@ -0,0 +1,51 @@ + */ +class UncastEnvValueRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return self::getContainer()->getByType(UncastEnvValueRule::class); + } + + public function testConfigFile(): void + { + $error = static fn (string $reader, string $type, int $line): array => [ + $reader . ' can return a string, but its default is ' . $type . '.', + $line, + "Cast it in the config file to its default's type, such as (int) env('DB_PORT', 3306). When the variable holds a name or other text, use a string default instead.", + ]; + + // The readers fixture declares an Env subclass with its own get(). + $this->analyse([__DIR__ . '/data/config/uncast-env.php', __DIR__ . '/data/env-readers.php'], [ + $error("env('DB_PORT')", 'an int', 14), + $error("Hypervel\\Support\\Env::get('APP_DEBUG')", 'a bool', 15), + $error("env('RATIO')", 'a float', 16), + $error("env('DEFAULT_TIMEOUT')", 'an int', 17), + $error("env('RETRIES')", 'an int', 18), + $error("env('SERVER_WEIGHT')", 'an int', 19), + $error("env('IDLE')", 'an int', 27), + $error("env('REPLICA_PORT')", 'an int', 28), + $error("env('ASSIGNED_PORT')", 'an int', 29), + $error("Hypervel\\Testbench\\Foundation\\Env::get('TESTBENCH_PORT')", 'an int', 30), + ]); + } + + public function testOtherFile(): void + { + $this->analyse([__DIR__ . '/data/uncast-env.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/env-calls.neon']; + } +} diff --git a/tests/PHPStan/Rules/UnsupportedCallRuleTest.php b/tests/PHPStan/Rules/UnsupportedCallRuleTest.php new file mode 100644 index 000000000..bd4ac7f7a --- /dev/null +++ b/tests/PHPStan/Rules/UnsupportedCallRuleTest.php @@ -0,0 +1,83 @@ + */ +class UnsupportedCallRuleTest extends RuleTestCase +{ + private const string MESSAGE = '%s::%s() is not supported by this implementation and always throws.'; + + protected function getRule(): Rule + { + $facadeHelper = new FacadeHelper( + $this->createReflectionProvider(), + self::getContainer()->getService('cachedRichParser'), + self::getContainer()->getByType(RuntimeInputs::class), + ); + + $facadeHelper->setContainer($this->createApplication()); + + return new UnsupportedCallRule($this->createReflectionProvider(), $facadeHelper); + } + + public function testRule(): void + { + $bare = sprintf(self::MESSAGE, Service::class, 'bare'); + $guided = sprintf(self::MESSAGE, Service::class, 'guided') . "\nUse supported() instead."; + + $this->analyse([__DIR__ . '/data/unsupported-calls.php'], [ + [$bare, 178], + [$bare, 179], + [$guided, 180], + [sprintf(self::MESSAGE, PendingRequest::class, 'setClient') . "\nUse setHandler() to configure a request-specific transport handler.", 181], + [$bare, 183], + [sprintf(self::MESSAGE, ColumnDefinition::class, 'whereNotNull') . "\nChain it onto an index: \$table->index(...)->whereNotNull(...).", 184], + [$guided, 185], + [sprintf(self::MESSAGE, ForwardingFacade::class, 'retired'), 197], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-rules.neon']; + } + + /** + * Create the booted application the facade roots are read from. + */ + private function createApplication(): Container + { + $container = new Container; + + $container->singleton('unsupported.factory', static fn (): Service => new Service); + $container->instance('unsupported.instance', new ColumnDefinition); + $container->bind('unsupported.chain', ServiceContract::class); + $container->bind(ServiceContract::class, Service::class); + + // The rule can't know what an extender returns, so a call through one isn't reported. + $container->singleton('unsupported.extended', static fn (): Service => new Service); + $container->extend('unsupported.extended', static fn (Service $service): Service => $service); + + // Two factories on one line, which reflection's line numbers cannot tell apart. + [$factory] = [static fn (): Service => new Service, static fn (): Service => new Service]; + $container->bind('unsupported.shared-line', $factory); + + return $container; + } +} diff --git a/tests/PHPStan/Rules/data/binding-cycles.php b/tests/PHPStan/Rules/data/binding-cycles.php new file mode 100644 index 000000000..61f96c121 --- /dev/null +++ b/tests/PHPStan/Rules/data/binding-cycles.php @@ -0,0 +1,41 @@ +app->singleton('auth', AuthManager::class); + app()->bind('cache', CacheManager::class); + App::scoped('auth', AuthManager::class); + Container::getInstance()->singleton('cache', CacheManager::class); + $this->app->singletonIf('auth', AuthManager::class); + $this->app->singleton('auth', fn ($app) => new AuthManager($app)); + $this->app->singleton(AuthManager::class); + $this->app->singleton(CacheManager::class, AuthManager::class); + (new Container)->singleton('auth', AuthManager::class); + TenantApp::singleton('auth', AuthManager::class); + } + + public function configure(Application $application): void + { + $application->bind('auth', AuthManager::class); + } +} diff --git a/tests/PHPStan/Rules/data/config-collection-rule.php b/tests/PHPStan/Rules/data/config-collection-rule.php index 5d9f97121..3dd5a08ea 100644 --- a/tests/PHPStan/Rules/data/config-collection-rule.php +++ b/tests/PHPStan/Rules/data/config-collection-rule.php @@ -2,7 +2,6 @@ namespace ConfigCollectionRule; -use Hypervel\Config\Repository; use Hypervel\Support\Facades\Config; class Foo @@ -14,9 +13,9 @@ public function bar(): void Config::collection('test.bar', 5); } - public function receivers(Repository $config): void + public function receivers(): void { - $config->collection('test.foo'); + app('config')->collection('test.foo'); config()->collection('test.foo'); } diff --git a/tests/PHPStan/Rules/data/config-type-rule.php b/tests/PHPStan/Rules/data/config-type-rule.php index 451033fba..fd4817a06 100644 --- a/tests/PHPStan/Rules/data/config-type-rule.php +++ b/tests/PHPStan/Rules/data/config-type-rule.php @@ -9,11 +9,11 @@ class Foo { - public function getters(Repository $config): void + public function getters(): void { Config::integer('test.foo'); Config::string('test.foo'); - $config->array('test.foo'); + app('config')->array('test.foo'); config()->float('auth.defaults.guard'); } diff --git a/tests/PHPStan/Rules/data/config/uncast-env.php b/tests/PHPStan/Rules/data/config/uncast-env.php new file mode 100644 index 000000000..7fdbfd15c --- /dev/null +++ b/tests/PHPStan/Rules/data/config/uncast-env.php @@ -0,0 +1,32 @@ + env('DB_PORT', 3306), + 'debug' => Env::get('APP_DEBUG', false), + 'ratio' => env('RATIO', 1.5) ?: 2.0, + 'timeout' => env('TIMEOUT') ?? env('DEFAULT_TIMEOUT', 30), + 'retries' => $retries ? env('RETRIES', 3) : 0, + 'servers' => [env('SERVER_WEIGHT', -1)], + 'cast' => (int) env('CAST', 3306), + 'filtered' => filter_var(env('FILTERED', false), FILTER_VALIDATE_BOOL), + 'scheme' => env('REDIS_TLS', false) ? 'tls' : 'tcp', + 'nullable' => ($ttl = env('TTL', 60)) === null ? null : (int) $ttl, + 'delay' => $delay === null ? null : (int) $delay, + 'name' => env('APP_NAME', 'Hypervel'), + 'host' => env('DB_HOST'), + 'idle' => env('CACHE_IDLE', env('IDLE', 60)), + 'replica_port' => env('REPLICA_PORT', $defaultPort), + 'assigned' => ($assignedPort = env('ASSIGNED_PORT', 8080)), + 'testbench' => TestbenchEnv::get('TESTBENCH_PORT', 8081), + 'cached' => CachedEnv::get('CACHED_PORT', 8082), +]; diff --git a/tests/PHPStan/Rules/data/discarded-dates.php b/tests/PHPStan/Rules/data/discarded-dates.php new file mode 100644 index 000000000..1ca3fab47 --- /dev/null +++ b/tests/PHPStan/Rules/data/discarded-dates.php @@ -0,0 +1,94 @@ +addDays(1); + } +} + +class Schedule +{ + public function immutable(CarbonImmutable $date, HypervelCarbonImmutable $hypervel, ?CarbonImmutable $optional): void + { + $date->addDay(); + $date->subMonths(2); + $date->startOfDay(); + $date->endOfMonth(); + $date->next('Monday'); + $date->previousWeekday(); + $date->nthOfMonth(2, 1); + $date->firstOfQuarter(); + $date->lastOfYear(); + $date->roundHour(); + $date->floorDay(); + $date->ceilMinute(); + $date->modify('+1 day'); + $date->setTimezone('UTC'); + $date->year(2030); + $date->setMicroseconds(5); + $hypervel->addDays(2); + $hypervel->plus(days: 1); + $optional?->utc(); + $date->addDay()->startOfDay(); + $date = $date->addDay(); + $date->format('Y-m-d'); + $date->locale('fr'); + $date->settings(['monthOverflow' => false]); + } + + public function getterSetters(CarbonImmutable $date, ?int $day, array $arguments): void + { + $date->dayOfYear(5); + $date->dayOfYear(value: 5); + $date->dayOfWeek(3); + $date->tz('UTC'); + $date->dayOfYear($day); + $date->week(...$arguments); + $date->dayOfYear(); + $date->dayOfYear(null); + $date->tz(); + $date->weekOfYear(); + } + + public function contracts(CarbonInterface $date, Carbon|CarbonImmutable $either): void + { + $date->addDay(); + $date->dayOfYear(5); + $either->setTime(9, 0); + } + + public function mutable(Carbon $date, DateTimeImmutable $native): void + { + $date->addDay(); + $date->dayOfYear(5); + $native->modify('+1 day'); + } + + public function userMethods(ShiftDate $date): void + { + $date->addDay(); + $date->addBusinessDays(2); + $date->subDay(); + } + + public function firstClassCallables(CarbonImmutable $date): void + { + $date->addDay(...); + $date->dayOfYear(...); + } +} diff --git a/tests/PHPStan/Rules/data/env-readers.php b/tests/PHPStan/Rules/data/env-readers.php index e54c1d677..c5d06ac1d 100644 --- a/tests/PHPStan/Rules/data/env-readers.php +++ b/tests/PHPStan/Rules/data/env-readers.php @@ -19,3 +19,14 @@ // Other Env methods do not read a variable Env::getRepository(); + +// A subclass's own reader runs its own body +CachedEnv::get('foo'); + +class CachedEnv extends Env +{ + public static function get(string $key, mixed $default = null): mixed + { + return $default; + } +} diff --git a/tests/PHPStan/Rules/data/managed-transactions.php b/tests/PHPStan/Rules/data/managed-transactions.php new file mode 100644 index 000000000..de67e91ab --- /dev/null +++ b/tests/PHPStan/Rules/data/managed-transactions.php @@ -0,0 +1,210 @@ + $this->connection; + } + + public function __construct( + private ConnectionInterface $connection, + private Ledger $ledger, + ) { + } + + public function endedEarly(): void + { + DB::transaction(function () { + DB::commit(); + }); + DB::transaction(fn () => DB::rollBack()); + DB::transaction(function (ConnectionInterface $connection) { + $connection->rollBack(null); + }); + DB::connection('mysql')->transaction(function () { + DB::connection('mysql')->commit(); + }); + + $connection = $this->connection; + $connection->transaction(function () use ($connection) { + $connection->commit(); + }); + DB::transaction(function () { + $this->ledger->commit(); + DB::commit(); + }); + } + + public function rollsBackToZero(): void + { + DB::transaction(function () { + DB::beginTransaction(); + DB::rollBack(0); + }); + DB::transaction(function () { + DB::rollBack(toLevel: 0); + }); + } + + public function balanced(): void + { + DB::transaction(function () { + DB::beginTransaction(); + DB::beginTransaction(); + DB::commit(); + DB::rollBack(); + $this->ledger->commit(); + }); + DB::transaction(function () { + DB::beginTransaction(); + DB::rollBack(2); + DB::commit(); + }); + } + + public function uncertain(bool $retry): void + { + $connection = $this->connection; + + DB::transaction(function () use ($retry) { + if ($retry) { + DB::beginTransaction(); + } + + DB::commit(); + }); + DB::transaction(function () { + return true; + + DB::commit(); + }); + DB::transaction(function () { + DB::connection('mysql')->commit(); + DB::commit(); + }); + $connection->transaction(function () use ($connection) { + $connection = DB::connection('other'); + $connection->commit(); + }); + DB::transaction(function () use ($retry) { + $retry ? DB::commit() : DB::rollBack(); + }); + DB::transaction(function () { + DB::afterCommit(function () { + DB::commit(); + }); + }); + } + + public function overrides(PdoConnection $inherited, RetryingConnection $retrying, AuditedConnection $audited): void + { + $inherited->transaction(function (PdoConnection $connection) { + $connection->commit(); + }); + $retrying->transaction(function (PdoConnection $connection) { + $connection->commit(); + }); + $audited->transaction(function (AuditedConnection $connection) { + $connection->commit(); + }); + TenantDB::transaction(function () { + DB::commit(); + }); + TenantDB::connection('mysql')->transaction(function () { + TenantDB::connection('mysql')->commit(); + }); + } + + public function properties(): void + { + $this->connection->transaction(function () { + $this->connection->commit(); + }); + $this->connection->transaction(fn () => $this->connection->rollBack()); + $this->connection->transaction(function () { + $this->connection = DB::connection('other'); + $this->connection->commit(); + }); + $this->connection->transaction(function () { + [$this->connection] = [DB::connection('other')]; + $this->connection->commit(); + }); + $this->current->transaction(function () { + $this->current->commit(); + }); + } + + public function destructuring(): void + { + $connection = $this->connection; + + $connection->transaction(function () use ($connection) { + [$connection] = [DB::connection('other')]; + $connection->commit(); + }); + DB::transaction(function (ConnectionInterface $connection) { + ['main' => $connection] = ['main' => DB::connection('other')]; + $connection->commit(); + }); + } + + public function generators(RetryingConnection $retrying): void + { + DB::transaction(function () { + yield DB::table('transfers')->get(); + }); + $this->connection->transaction(fn () => yield DB::table('transfers')->count()); + DB::transaction(function () { + return (function () { + yield DB::table('transfers')->get(); + })(); + }); + $retrying->transaction(function () { + yield DB::table('transfers')->get(); + }); + } +} diff --git a/tests/PHPStan/Rules/data/migration-column-types.neon b/tests/PHPStan/Rules/data/migration-column-types.neon new file mode 100644 index 000000000..8f79280c9 --- /dev/null +++ b/tests/PHPStan/Rules/data/migration-column-types.neon @@ -0,0 +1,4 @@ +includes: + - ../phpstan-rules.neon +parameters: + checkMigrationColumnTypes: true diff --git a/tests/PHPStan/Rules/data/migration-column-types.php b/tests/PHPStan/Rules/data/migration-column-types.php new file mode 100644 index 000000000..14f6c7ad6 --- /dev/null +++ b/tests/PHPStan/Rules/data/migration-column-types.php @@ -0,0 +1,38 @@ +jsonb($column); + } +} + +class CreateOrdersTable extends Migration +{ + public function up(): void + { + Schema::create('orders', function (Blueprint $table) { + $table->enum('status', ['pending', 'paid']); + $table->set('flags', ['gift', 'rush']); + $table->json('metadata')->nullable(); + $table->string('state'); + $table->jsonb('options'); + $table->text('payload'); + }); + } + + public function audit(AuditBlueprint $table): void + { + $table->json('changes'); + } +} diff --git a/tests/PHPStan/Rules/data/mutable-dates.php b/tests/PHPStan/Rules/data/mutable-dates.php new file mode 100644 index 000000000..4aad9fdea --- /dev/null +++ b/tests/PHPStan/Rules/data/mutable-dates.php @@ -0,0 +1,43 @@ +midDay(); + } +} + +class Billing +{ + public function dates(?string $value): void + { + $dates = [ + new Carbon('2030-01-01'), + new BaseCarbon, + Carbon::now(), + BaseCarbon::parse('2030-01-01'), + Carbon::createFromFormat('Y-m-d', '2030-01-01'), + Carbon::make($value), + Carbon::hasTestNow(), + Carbon::getTestNow(), + LocalCarbon::now(), + new LocalCarbon, + CarbonImmutable::now(), + Date::now(), + now(), + Carbon::create(...), + ]; + + Carbon::setTestNow(); + } +} diff --git a/tests/PHPStan/Rules/data/native-request-apis.php b/tests/PHPStan/Rules/data/native-request-apis.php new file mode 100644 index 000000000..587082c0c --- /dev/null +++ b/tests/PHPStan/Rules/data/native-request-apis.php @@ -0,0 +1,48 @@ + $arguments + */ + public function read(int $type, array $arguments): void + { + getallheaders(); + apache_request_headers(); + filter_input(INPUT_GET, 'page'); + filter_input_array(type: INPUT_POST); + filter_has_var(INPUT_COOKIE, 'seen'); + filter_has_var(input_type: INPUT_GET, var_name: 'page'); + + filter_input(INPUT_SERVER, 'REQUEST_URI'); + filter_input(INPUT_ENV, 'APP_NAME'); + filter_input($type, 'page'); + filter_input(...$arguments); + } +} diff --git a/tests/PHPStan/Rules/data/observer-attributes.php b/tests/PHPStan/Rules/data/observer-attributes.php new file mode 100644 index 000000000..46d8b1210 --- /dev/null +++ b/tests/PHPStan/Rules/data/observer-attributes.php @@ -0,0 +1,75 @@ +observers()); + Refund::observe(InvoiceObserver::class); + AuditEntry::observe(InvoiceObserver::class); + LedgerEntry::observe(InvoiceObserver::class); + + if ($this->app->runningInConsole()) { + Invoice::observe(InvoiceObserver::class); + } + + $this->app->booted(function (): void { + Invoice::observe(InvoiceObserver::class); + }); + } + + public function observers(): array + { + return [InvoiceObserver::class]; + } +} + +class Billing +{ + public function boot(): void + { + Invoice::observe(InvoiceObserver::class); + } +} diff --git a/tests/PHPStan/Rules/data/observer-packages/ledger/LedgerEntry.php b/tests/PHPStan/Rules/data/observer-packages/ledger/LedgerEntry.php new file mode 100644 index 000000000..1790d26a3 --- /dev/null +++ b/tests/PHPStan/Rules/data/observer-packages/ledger/LedgerEntry.php @@ -0,0 +1,11 @@ + [ + 'name' => 'acme/application', + 'pretty_version' => 'dev-main', + 'version' => 'dev-main', + 'reference' => null, + 'type' => 'project', + 'install_path' => __DIR__ . '/../../', + 'aliases' => [], + 'dev' => true, + ], + 'versions' => [ + 'acme/application' => [ + 'pretty_version' => 'dev-main', + 'version' => 'dev-main', + 'reference' => null, + 'type' => 'project', + 'install_path' => __DIR__ . '/../../', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'acme/audit' => [ + 'pretty_version' => '1.0.0', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'library', + 'install_path' => __DIR__ . '/../acme', + 'aliases' => [], + 'dev_requirement' => false, + ], + 'acme/ledger' => [ + 'pretty_version' => 'dev-main', + 'version' => 'dev-main', + 'reference' => null, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../observer-packages/ledger', + 'aliases' => [], + 'dev_requirement' => false, + ], + ], +]; diff --git a/tests/PHPStan/Rules/data/request-dependent-bindings-local-request.php b/tests/PHPStan/Rules/data/request-dependent-bindings-local-request.php new file mode 100644 index 000000000..d7e50d2e4 --- /dev/null +++ b/tests/PHPStan/Rules/data/request-dependent-bindings-local-request.php @@ -0,0 +1,25 @@ +is('api/*'); })] +interface UsesRequestHelper +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return RequestFacade::is('api/*'); })] +interface UsesRequestFacade +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return RequestContext::has(); })] +interface UsesRequestContext +{ +} + +#[BindWhen(Implementation::class, static function (): bool { + $driver = Auth::getDefaultDriver(); + + return Auth::check(); +})] +interface UsesAuthFacade +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return auth()->guard('web')->user() !== null; })] +interface UsesGuard +{ +} + +#[BindWhen(Implementation::class, static function (Container $app): bool { return $app->make(Request::class)->is('api/*'); })] +interface ResolvesRequestAlias +{ +} + +#[BindWhen(condition: static function (): bool { return app('request')->is('api/*'); }, concrete: Implementation::class)] +interface ResolvesRequestByName +{ +} + +#[BindWhen(Implementation::class, Conditions::onApi(...))] +interface UsesConditionMethod +{ +} + +#[BindWhen(Implementation::class, onAdminPath(...))] +interface UsesConditionFunction +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return Auth::getDefaultDriver() === 'web'; })] +interface ReadsAuthConfiguration +{ +} + +#[BindWhen(Implementation::class, static function (Container $app): bool { return $app->make('config')->get('app.env') === 'production'; })] +interface ReadsConfiguration +{ +} + +#[BindWhen(Implementation::class, Conditions::inProduction(...))] +interface UsesBootStableMethod +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return RequestFacade::hasMacro('feature'); })] +interface ReadsRequestMacros +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return BootRequestContext::has() || BootAuth::check() || BootAuth::guard()->check(); })] +interface UsesOverridingReaders +{ +} + +#[BindWhen(Implementation::class, static function (Container $app): bool { + $app = new ContainerImplementation; + + return $app->make('request')->is('api/*'); +})] +interface ReassignsContainer +{ +} + +#[BindWhen(Implementation::class, LocalRequest\onLocalFlag(...))] +interface UsesLocalFunctionInAnotherNamespace +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return array_any(['api/*', 'v2/*'], static fn (string $pattern): bool => request()->is($pattern)); })] +interface ReadsInCallback +{ +} + +#[BindWhen(Implementation::class, static function (Container $app): bool { + $reset = static function (): bool { + $app = null; + + return $app === null; + }; + + return $reset() && array_any(['api/*'], static function (string $pattern) use ($app): bool { + return $app->make('request')->is($pattern); + }); +})] +interface CapturesContainer +{ +} + +#[BindWhen(Implementation::class, static function (Container $app): bool { return array_any( + [new ContainerImplementation], + static fn (ContainerImplementation $app): bool => $app->make('request')->is('api/*'), +); })] +interface ShadowsContainer +{ +} + +#[BindWhen(Implementation::class, static function (Container $app): bool { + return array_any(['api/*'], static function (string $pattern): bool { + return $app->make('request')->is($pattern); + }); +})] +interface LeavesContainerUncaptured +{ +} + +#[BindWhen(Implementation::class, RequestContext::has(...))] +interface NamesRequestContextRead +{ +} + +#[BindWhen(Implementation::class, Auth::check(...))] +interface NamesAuthRead +{ +} + +#[BindWhen(Implementation::class, BootAuth::check(...))] +interface NamesOverridingRead +{ +} + +#[BindWhen(Implementation::class, Conditions::onRequestAbstract(...))] +interface UsesConditionConstant +{ +} + +class BootRequestContext extends RequestContext +{ + public static function has(?int $coroutineId = null): bool + { + return false; + } +} + +class BootAuth extends Auth +{ + public static function check(): bool + { + return false; + } + + public static function guard(): Guard + { + return Auth::guard('boot'); + } +} + +class Conditions +{ + private const string REQUEST = 'request'; + + public static function onApi(Container $container): bool + { + return $container->make('request')->is('api/*'); + } + + public static function onRequestAbstract(Container $container): bool + { + return $container->make(self::REQUEST)->is('api/*'); + } + + public static function inProduction(Container $container): bool + { + return $container->make('config')->get('app.env') === 'production'; + } +} + +function onAdminPath(): bool +{ + return request()->is('admin/*'); +} + +#[BindWhen(Implementation::class, self::onApi(...))] +abstract class SelectsWithOwnMethod +{ + public static function onApi(): bool + { + return request()->is('api/*'); + } +} + +#[BindWhen(Implementation::class, parent::onApi(...))] +abstract class SelectsWithParentMethod extends SelectsWithOwnMethod +{ +} + +#[BindWhen(Implementation::class, static function (): bool { return RoutedAuth::check() || RoutedAuth::guard()->check() || RoutedRequest::is('api/*'); })] +interface UsesFacadesWithOwnForwarding +{ +} + +class RoutedAuth extends Auth +{ + public static function __callStatic(string $method, array $args): mixed + { + return false; + } +} + +class RoutedRequest extends RequestFacade +{ + public static function __callStatic(string $method, array $args): mixed + { + return false; + } +} + +#[BindWhen(Implementation::class, static function (Container $app): bool { + [$app] = [new ContainerImplementation]; + + return $app->make('request')->is('api/*'); +})] +interface DestructuresContainer +{ +} diff --git a/tests/PHPStan/Rules/data/socialite-properties.php b/tests/PHPStan/Rules/data/socialite-properties.php new file mode 100644 index 000000000..86cc50d66 --- /dev/null +++ b/tests/PHPStan/Rules/data/socialite-properties.php @@ -0,0 +1,73 @@ +usesPKCE = true; + } + + public function withSecret(string $secret): static + { + $this->clientSecret = $secret; + + return $this; + } + + public function getScopes(): array + { + return [...$this->scopes, 'openid']; + } + + protected function getAuthUrl(?string $state): string + { + return $this->buildAuthUrlFromBase('https://example.com/auth?scope=' . implode($this->scopeSeparator, $this->scopes), $state); + } + + protected function getTokenUrl(): string + { + return 'https://example.com/token?' . http_build_query($this->parameters); + } + + protected function getUserByToken(string $token): array + { + return []; + } + + protected function mapUserToObject(array $user): User + { + return (new User)->setRaw($user); + } +} + +class CustomFacebookProvider extends FacebookProvider +{ + public function opensInPopup(): bool + { + return $this->popup; + } +} + +class FieldsGithubProvider extends GithubProvider +{ + protected array $fields = ['login']; + + public function requestedFields(): array + { + return $this->fields; + } +} diff --git a/tests/PHPStan/Rules/data/typed-config-getters.php b/tests/PHPStan/Rules/data/typed-config-getters.php new file mode 100644 index 000000000..1c5ce453e --- /dev/null +++ b/tests/PHPStan/Rules/data/typed-config-getters.php @@ -0,0 +1,32 @@ +get('typed.debug'); + config('typed.hosts', []); + config($secondary ? 'typed.name' : 'typed.label'); + config('typed.either'); + config('typed.nullable'); + config('typed.missing', 'fallback'); + config()->string('typed.name'); + Config::string('typed.name'); + config(['typed.name' => 'Other']); + + // Only the application's repository has the application's values + $config->get('typed.name'); + (new ConfigRepository(['typed' => ['name' => 123]]))->get('typed.name'); + } +} diff --git a/tests/PHPStan/Rules/data/uncast-env.php b/tests/PHPStan/Rules/data/uncast-env.php new file mode 100644 index 000000000..0eb8b3cb2 --- /dev/null +++ b/tests/PHPStan/Rules/data/uncast-env.php @@ -0,0 +1,7 @@ + env('DB_PORT', 3306), +]; diff --git a/tests/PHPStan/Rules/data/unsupported-calls.php b/tests/PHPStan/Rules/data/unsupported-calls.php new file mode 100644 index 000000000..4bd5c0b03 --- /dev/null +++ b/tests/PHPStan/Rules/data/unsupported-calls.php @@ -0,0 +1,227 @@ +bare(); + } + + /** + * Reject the operation, naming the alternative. + */ + #[Unsupported(message: 'Use supported() instead.')] + public static function guided(): never + { + throw new LogicException('Unsupported.'); + } + + /** + * Run the supported operation. + */ + public function supported(): void + { + } +} + +class OverridingService extends Service +{ + /** + * Run the operation this implementation supports. + */ + public function bare(): never + { + throw new LogicException('Overridden.'); + } +} + +class SelfBuildingService extends Service implements SelfBuilding +{ + /** + * Create a new instance. + */ + public static function newInstance(): static + { + return new static; + } +} + +class FactoryFacade extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'unsupported.factory'; + } +} + +class InstanceFacade extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'unsupported.instance'; + } +} + +class ChainFacade extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'unsupported.chain'; + } +} + +class ExtendedFacade extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'unsupported.extended'; + } +} + +class SharedLineFacade extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'unsupported.shared-line'; + } +} + +class SelfBuildingFacade extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return SelfBuildingService::class; + } +} + +class ResolvingFacade extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'unsupported.factory'; + } + + /** + * Resolve the facade root instance another way. + */ + protected static function resolveFacadeInstance(string $name): mixed + { + return new OverridingService; + } +} + +function test( + Service $service, + ?Service $optional, + OverridingService $overriding, + ServiceContract $contract, + ApiPendingRequest $apiRequest, + HttpPendingRequest $httpRequest, + ClientInterface $client, +): void { + // Each call is its own function, as code after a call that never returns is unreachable. + $calls = [ + fn () => $service->bare(), + fn () => $optional?->BARE(), + fn () => Service::guided(), + fn () => $apiRequest->setClient($client), + + fn () => FactoryFacade::bare(), + fn () => InstanceFacade::whereNotNull('email'), + fn () => ChainFacade::guided(), + + fn () => $service->bare(...), + fn () => $service->supported(), + fn () => $overriding->bare(), + fn () => $contract->bare(), + fn () => $httpRequest->setClient($client), + fn () => ExtendedFacade::bare(), + fn () => SharedLineFacade::bare(), + fn () => SelfBuildingFacade::bare(), + fn () => ResolvingFacade::bare(), + fn () => ForwardingFacade::bare(), + fn () => ForwardingFacade::retired(), + ]; +} + +class ForwardingFacade extends Facade +{ + /** + * Forward a static call to another implementation. + */ + public static function __callStatic(string $method, array $args): mixed + { + return (new OverridingService)->{$method}(...$args); + } + + /** + * Reject the facade's own operation. + */ + #[Unsupported] + public static function retired(): never + { + throw new LogicException('Unsupported.'); + } + + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'unsupported.factory'; + } +} diff --git a/tests/PHPStan/RuntimeInputsDependencyTest.php b/tests/PHPStan/RuntimeInputsDependencyTest.php index b6c1fac8d..80832edbf 100644 --- a/tests/PHPStan/RuntimeInputsDependencyTest.php +++ b/tests/PHPStan/RuntimeInputsDependencyTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\PHPStan; use Hypervel\Filesystem\Filesystem; +use PHPUnit\Framework\Attributes\Large; use Symfony\Component\Process\Process; use function dirname; @@ -20,13 +21,14 @@ * analyzed file references: provider and helper code, configuration, the environment, routes, * and the classes and views the boot selected. */ +#[Large] class RuntimeInputsDependencyTest extends ResultCacheTestCase { /** - * The global type alias the alias fixture uses, and one file per job, so the main process and - * several workers each compute the values. + * The global type alias the alias fixture uses, and one file per job across two workers, so the + * main process and each worker compute the values. */ - private const string PARAMETERS = "typeAliases:\n RenderView: 'Closure(view-string): void'\nparallel:\n jobSize: 1"; + private const string PARAMETERS = "typeAliases:\n RenderView: 'Closure(view-string): void'\nparallel:\n jobSize: 1\n maximumNumberOfProcesses: 2"; /** * The diagnostics of the unedited boot inputs. diff --git a/tests/PHPStan/RuntimeInputsPackageDependencyTest.php b/tests/PHPStan/RuntimeInputsPackageDependencyTest.php index 10b4bafcd..9c350c2cd 100644 --- a/tests/PHPStan/RuntimeInputsPackageDependencyTest.php +++ b/tests/PHPStan/RuntimeInputsPackageDependencyTest.php @@ -5,13 +5,18 @@ namespace Hypervel\Tests\PHPStan; use Hypervel\Filesystem\Filesystem; +use Hypervel\Tests\PHPStan\Fixtures\RuntimeInputs\Package\FrenchGreeterServiceProvider; use Hypervel\Tests\PHPStan\Fixtures\RuntimeInputs\Package\PackageServiceProvider; +use PHPUnit\Framework\Attributes\Large; use Symfony\Component\Process\Process; use function dirname; use function file_get_contents; use function file_put_contents; use function json_encode; +use function mkdir; +use function rmdir; +use function symlink; use function touch; use function unlink; @@ -19,6 +24,7 @@ * A package's analysis boots a disposable Testbench copy of its skeleton in every process, so * what the boot decides depends on the skeleton, the Testbench YAML and the environment. */ +#[Large] class RuntimeInputsPackageDependencyTest extends ResultCacheTestCase { /** @@ -66,6 +72,43 @@ public function testPackageInputChangesReanalyzeTheirConsumers(): void $this->assertStringNotContainsString("env-pattern\t*", (string) file_get_contents($packagePath . '/cache/resultCache.php')); } + public function testVendorSourceChangesReanalyzeTheirConsumers(): void + { + $packagePath = $this->createPackage(); + $skeletonVendorPath = $packagePath . '/skeleton/vendor'; + $unset = ['PACKAGE_GREETER' => false, 'MYSQL_DATABASE' => false, 'TEST_TOKEN' => false]; + $englishVendorPath = $this->createVendor($this->tempDir . '/english', french: false); + $frenchVendorPath = $this->createVendor($this->tempDir . '/french', french: true); + + // Without a skeleton vendor directory, the runtime copy links the package's. + symlink($englishVendorPath, $packagePath . '/vendor'); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + + $process = $this->analysePackage($packagePath, $unset); + $this->assertSame([self::ENGLISH], $this->diagnostics($process, 1)); + $this->assertReanalyzed(0, $process); + + // The package's vendor link is repointed while its old target stays. + unlink($packagePath . '/vendor'); + symlink($frenchVendorPath, $packagePath . '/vendor'); + $this->assertSame([], $this->diagnostics($this->analysePackage($packagePath, $unset), 0)); + + // An empty skeleton vendor directory is copied instead, so no package is discovered. + mkdir($skeletonVendorPath); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + rmdir($skeletonVendorPath); + $this->assertSame([], $this->diagnostics($this->analysePackage($packagePath, $unset), 0)); + + // Skeleton metadata appears after the package's was used. + unlink($packagePath . '/vendor'); + symlink($englishVendorPath, $packagePath . '/vendor'); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + $this->createVendor($skeletonVendorPath, french: true); + $this->assertSame([], $this->diagnostics($this->analysePackage($packagePath, $unset), 0)); + (new Filesystem)->deleteDirectory($skeletonVendorPath); + $this->assertSame([self::ENGLISH], $this->diagnostics($this->analysePackage($packagePath, $unset), 1)); + } + /** * Create a package whose Testbench YAML names a copy of the default skeleton. */ @@ -85,6 +128,22 @@ private function createPackage(): string return $packagePath; } + /** + * Create a vendor directory whose Composer metadata lists the French greeter's package, or no packages. + */ + private function createVendor(string $vendorPath, bool $french): string + { + $packages = $french ? [[ + 'name' => 'hypervel-phpstan/french-greeter', + 'extra' => ['hypervel' => ['providers' => [FrenchGreeterServiceProvider::class]]], + ]] : []; + + mkdir($vendorPath . '/composer', 0777, true); + file_put_contents($vendorPath . '/composer/installed.json', json_encode(['packages' => $packages], JSON_THROW_ON_ERROR)); + + return $vendorPath; + } + /** * Run PHPStan from the package over the package fixture directory, one file per job. * diff --git a/tests/PHPStan/Type/data/config-provider-sources.php b/tests/PHPStan/Type/data/config-provider-sources.php index bcc1cbca6..4f1652c74 100644 --- a/tests/PHPStan/Type/data/config-provider-sources.php +++ b/tests/PHPStan/Type/data/config-provider-sources.php @@ -2,7 +2,6 @@ namespace ConfigProviderSources; -use Hypervel\Contracts\Config\Repository; use Hypervel\Support\Facades\Config; use function PHPStan\Testing\assertType; @@ -12,12 +11,12 @@ * the applications using the package replace them. ConfigCompositionTest checks how an * application's analysis composes the same sources. */ -function test(Repository $config): void +function test(): void { assertType('mixed', config('package.key')); assertType('mixed', config('package.env')); assertType('mixed', config('property.key')); - assertType('string', $config->string('package.key')); + assertType('string', config()->string('package.key')); // The Testbench application's files stay known, and a key only a package file sets is unknown rather than absent. assertType("array{shared: 'app', app: 'app', ...}", config('merged')); diff --git a/tests/PHPStan/Type/data/config-repository-method-l12-20.php b/tests/PHPStan/Type/data/config-repository-method-l12-20.php index c236504df..a98464fd7 100644 --- a/tests/PHPStan/Type/data/config-repository-method-l12-20.php +++ b/tests/PHPStan/Type/data/config-repository-method-l12-20.php @@ -5,11 +5,13 @@ use Hypervel\Config\Repository; use function PHPStan\Testing\assertType; +// Unlike upstream, only the application's repository has the application's values. function test(Repository $config): void { - assertType('*ERROR*', $config->collection('foo')); - assertType("Hypervel\Support\Collection<'guard'|'passwords', 'users'|'web'>", $config->collection('auth.defaults')); + assertType('*ERROR*', config()->collection('foo')); + assertType("Hypervel\Support\Collection<'guard'|'passwords', 'users'|'web'>", config()->collection('auth.defaults')); - assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', $config->collection('test.bar')); - assertType('*ERROR*', $config->collection('test.foo')); + assertType('Hypervel\Support\Collection<0|1|2, 1|2|3>', config()->collection('test.bar')); + assertType('*ERROR*', config()->collection('test.foo')); + assertType('Hypervel\Support\Collection<(int|string), mixed>', $config->collection('test.foo')); } diff --git a/tests/PHPStan/Type/data/config-repository-method.php b/tests/PHPStan/Type/data/config-repository-method.php index f5f6808db..5b389fc49 100644 --- a/tests/PHPStan/Type/data/config-repository-method.php +++ b/tests/PHPStan/Type/data/config-repository-method.php @@ -6,15 +6,22 @@ use Hypervel\Support\Facades\Config; use function PHPStan\Testing\assertType; -// Unlike upstream, an absent key returns its default and a present key ignores it. +// Unlike upstream, an absent key returns its default and a present key ignores it, and only the +// application's repository has the application's values: config() or a resolution of it from the +// application container. function test(Repository $config): void { - assertType('null', $config->get('foo')); - assertType('array{guard: \'web\', passwords: \'users\'}', $config->get('auth.defaults')); + assertType('null', config()->get('foo')); + assertType('array{guard: \'web\', passwords: \'users\'}', app('config')->get('auth.defaults')); - assertType('array{1, 2, 3}', $config->get('test.bar')); - assertType("'bar'", $config->get('test.foo')); - assertType("'bar'", $config->get('test.foo', 5)); + assertType('array{1, 2, 3}', config()->get('test.bar')); + assertType("'bar'", app(Repository::class)->get('test.foo')); + assertType("'bar'", config()->get('test.foo', 5)); + + // A repository received or built elsewhere may hold other values. + assertType('mixed', $config->get('test.foo')); + assertType('mixed', (new Repository(['test' => ['foo' => 123]]))->get('test.foo')); + assertType('string', $config->string('test.bar')); } class CustomValue @@ -52,7 +59,7 @@ function customImplementations(CustomRepository $config, CustomArrayRepository $ assertType('ConfigRepositoryMethod\CustomValue', $config->get('test.foo')); assertType('string', $config->string('test.foo')); assertType('Hypervel\Support\Collection<(int|string), mixed>', $arrayConfig->collection('test.bar')); - assertType("'bar'", $arrayConfig->string('test.foo')); + assertType('string', $arrayConfig->string('test.foo')); assertType('ConfigRepositoryMethod\CustomValue', CustomConfig::get('test.foo')); assertType("'bar'", CustomConfig::string('test.foo')); } diff --git a/tests/PHPStan/Unit/SourceFilesTest.php b/tests/PHPStan/Unit/SourceFilesTest.php index 157ca048d..6489ceb8d 100644 --- a/tests/PHPStan/Unit/SourceFilesTest.php +++ b/tests/PHPStan/Unit/SourceFilesTest.php @@ -5,7 +5,9 @@ namespace Hypervel\Tests\PHPStan\Unit; use Hypervel\PHPStan\Reflection\SourceFiles; +use Hypervel\Support\Attributes\Unsupported; use Hypervel\Tests\PHPStan\Fixtures\SourceFiles\EnumCaseDefault; +use Hypervel\Tests\PHPStan\Fixtures\SourceFiles\MarkedMethod; use Hypervel\Tests\PHPStan\Fixtures\SourceFiles\SelfReferencingConstants; use Hypervel\Tests\PHPStan\Fixtures\SourceFiles\UntypedWithoutDefault; use Hypervel\Tests\PHPStan\Fixtures\SourceFiles\Visibility; @@ -38,6 +40,18 @@ public function itFindsTheFileOfAUnitEnumCaseReadByAPropertyDefault(): void ); } + #[Test] + public function itFindsTheFilesOfAMethodAttributesArguments(): void + { + $reflectionProvider = $this->createReflectionProvider(); + $class = $reflectionProvider->getClass(MarkedMethod::class); + + $this->assertSame( + [(string) $reflectionProvider->getClass(Visibility::class)->getFileName()], + SourceFiles::forAttributeArguments($reflectionProvider, $class, Unsupported::class, 'handle'), + ); + } + /** * An untyped property without a default holds null but has no default expression. */ diff --git a/tests/PHPStan/WorkerStateDependencyTest.php b/tests/PHPStan/WorkerStateDependencyTest.php index a78a229ef..c6b03a38d 100644 --- a/tests/PHPStan/WorkerStateDependencyTest.php +++ b/tests/PHPStan/WorkerStateDependencyTest.php @@ -4,12 +4,14 @@ namespace Hypervel\Tests\PHPStan; +use PHPUnit\Framework\Attributes\Large; use PHPUnit\Framework\Attributes\TestWith; use function file_get_contents; use function file_put_contents; use function str_replace; +#[Large] class WorkerStateDependencyTest extends ResultCacheTestCase { protected string $fixture = 'WorkerStateDependency'; @@ -39,4 +41,101 @@ public function testChangedLifetimesRecheckUnchangedWrites(bool $reuseCollectedD $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: $reuseCollectedDataRuleErrors), 0)); } + + /** + * Booted registrations decide lifetimes, which no analyzed file declares. + */ + #[TestWith([false])] + #[TestWith([true])] + public function testChangedRegistrationsRecheckUnchangedWrites(bool $reuseCollectedDataRuleErrors): void + { + $applicationPath = $this->copyApplication(); + + $this->edit($applicationPath . '/app/ReportService.php', '__construct()', '__construct(protected RequestCache $cache)'); + + $this->assertSame( + ['RequestCache.php:15 hypervel.workerSharedState'], + $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: $reuseCollectedDataRuleErrors), 1), + ); + + // PHPStan spells a stub-backed class's file path differently in a cold run, so the first warm run + // checks this rule again whatever changed. Start from a warm run. + if ($reuseCollectedDataRuleErrors) { + $this->assertSame(['RequestCache.php:15 hypervel.workerSharedState'], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 1)); + } + + $this->edit($applicationPath . '/bootstrap/app.php', '->create()', '->withScopedSingletons([WorkerStateDependency\RequestCache::class])->create()'); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: $reuseCollectedDataRuleErrors), 0)); + } + + /** + * A facade's root comes from its accessor's body, which exported nodes do not hold. + */ + public function testChangedFacadeAccessorsRecheckUnchangedCalls(): void + { + $applicationPath = $this->copyApplication(); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath), 0)); + + $this->edit($applicationPath . '/app/ReportFacade.php', 'return SupportedReporter::class;', 'return UnsupportedReporter::class;'); + + $this->assertSame(['Reporting.php:11 hypervel.unsupportedCall'], $this->diagnostics($this->analyse($applicationPath), 1)); + } + + /** + * Whether a wrapping exception keeps the cancellation depends on its constructor's body, which + * exported nodes do not hold. + */ + public function testChangedExceptionConstructorsRecheckUnchangedCatches(): void + { + $applicationPath = $this->copyApplication(); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 0)); + + $this->edit($applicationPath . '/app/ImportFailed.php', "parent::__construct('Import failed.', 0, \$cause);", 'parent::__construct($cause->getMessage());'); + + $this->assertSame(['ImportJob.php:17 hypervel.cancellation.swallowed'], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 1)); + } + + /** + * Whether a call passes a parameter's default depends on the declared default, which exported + * nodes record only as present, and on the constants it reads, which they do not hold at all. + */ + public function testChangedDefaultsRecheckUnchangedCalls(): void + { + $applicationPath = $this->copyApplication(); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 0)); + + $this->edit($applicationPath . '/app/Defaults.php', 'MAP = []', "MAP = ['default']"); + + $this->assertSame(['RebuildReport.php:13 hypervel.restrictedCall'], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 1)); + + $this->edit($applicationPath . '/app/Registry.php', 'array $map = Defaults::MAP', 'array $map = []'); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 0)); + } + + /** + * Whether the dispatcher reads a queued listener's debounce ID depends on its debounceFor default, + * which is not a declaration, here inherited from a parent whose collected data does not change. + */ + public function testChangedListenerDefaultsRecheckUnchangedWrites(): void + { + $applicationPath = $this->copyApplication(); + + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 0)); + + // PHPStan spells a stub-backed class's file path differently in a cold run, so the first warm run + // checks this rule again whatever changed. Start from a warm run. + $this->assertSame([], $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 0)); + + $this->edit($applicationPath . '/app/BaseReportListener.php', '$debounceFor = null;', '$debounceFor = 0;'); + + $this->assertSame( + ['BaseReportListener.php:11 hypervel.legacyQueueProperty', 'ReportListener.php:17 hypervel.staticStateMutation'], + $this->diagnostics($this->analyse($applicationPath, reuseCollectedDataRuleErrors: true), 1), + ); + } } diff --git a/tests/PHPStan/application/config/typed.php b/tests/PHPStan/application/config/typed.php new file mode 100644 index 000000000..9e9b0ab06 --- /dev/null +++ b/tests/PHPStan/application/config/typed.php @@ -0,0 +1,14 @@ + 'Hypervel', + 'label' => 'Typed', + 'port' => 3306, + 'ratio' => 1.5, + 'debug' => (bool) env('APP_DEBUG', false), + 'hosts' => ['primary', 'replica'], + 'either' => PHP_OS_FAMILY === 'Windows' ? 3306 : '/tmp/mysql.sock', + 'nullable' => null, +]; diff --git a/tests/Queue/Outbox/DatabaseOutboxRepositoryTest.php b/tests/Queue/Outbox/DatabaseOutboxRepositoryTest.php index b35caff3e..2faa1a564 100644 --- a/tests/Queue/Outbox/DatabaseOutboxRepositoryTest.php +++ b/tests/Queue/Outbox/DatabaseOutboxRepositoryTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Queue\Outbox; use Closure; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Events\TransactionBeginning; @@ -94,7 +94,8 @@ public function testExpiredClaimCanBeRecoveredWithoutChangingDeliveryOrPayload() public function testCustomNonPdoConnectionsUseTheConditionalClaimAndBindingFallback(): void { $database = DB::connection(); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); + $connection->shouldReceive('supportsSkipLocked')->andReturnFalse(); $connection->shouldReceive('table')->with('job_outbox')->andReturnUsing(fn (): Builder => $database->table('job_outbox')); $connection->shouldReceive('transaction')->andReturnUsing(fn (Closure $callback): mixed => $database->transaction($callback)); $resolver = m::mock(ConnectionResolverInterface::class); diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 2466b9f83..eb78e7291 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -16,7 +16,6 @@ use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\Connection; -use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\MySqlConnection; @@ -96,13 +95,15 @@ public function testQueueNamesPreserveZeroAndDefaultEmptyString(): void $this->assertSame('Emails', $queue->getQueue(UnitQueueName::Emails)); } - public function testLockForPoppingUsesTheConnectionCapability(): void + #[TestWith([true, 'FOR UPDATE SKIP LOCKED'])] + #[TestWith([false, true])] + public function testLockForPoppingUsesTheConnectionCapability(bool $supportsSkipLocked, bool|string $lock): void { $resolver = m::mock(ConnectionResolverInterface::class); $connection = m::mock(Connection::class); $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection); - $connection->shouldReceive('supportsSkipLocked')->once()->andReturn(true); + $connection->shouldReceive('supportsSkipLocked')->once()->andReturn($supportsSkipLocked); $queue = new TestDatabaseQueue( resolver: $resolver, @@ -112,23 +113,7 @@ public function testLockForPoppingUsesTheConnectionCapability(): void currentTime: 1732502704, ); - $this->assertSame('FOR UPDATE SKIP LOCKED', $queue->lockForPopping()); - } - - public function testLockForPoppingUsesAConservativeFallbackForInterfaceOnlyConnections(): void - { - $resolver = m::mock(ConnectionResolverInterface::class); - $resolver->shouldReceive('connection')->once()->with(null)->andReturn(m::mock(ConnectionInterface::class)); - - $queue = new TestDatabaseQueue( - resolver: $resolver, - connection: null, - table: 'table', - default: 'default', - currentTime: 1732502704, - ); - - $this->assertTrue($queue->lockForPopping()); + $this->assertSame($lock, $queue->lockForPopping()); } #[DataProvider('transientReservationFailureProvider')] @@ -205,7 +190,7 @@ public function testPushProperlyPushesJobOntoDatabase(Uuid $uuid, object|string currentTime: 1732502704, ); $queue->setContainer($container = m::spy(Container::class)->makePartial()); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $resolver->expects('connection')->andReturn($connection); $query = m::mock(Builder::class); $connection->expects('table')->with('table')->andReturn($query); @@ -260,7 +245,7 @@ public function testDelayedPushProperlyPushesJobOntoDatabase(DateInterval|DateTi currentTime: 1000, ); $queue->setContainer($container = m::spy(Container::class)->makePartial()); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $query = m::mock(Builder::class); $connection->expects('table')->with('table')->andReturn($query); $resolver->expects('connection')->andReturn($connection); @@ -308,7 +293,7 @@ public function testPushIncludesBatchIdInPayloadForBatchableJob(): void currentTime: 1732502704, ); $queue->setContainer($container = m::spy(Container::class)->makePartial()); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $resolver->expects('connection')->andReturn($connection); $query = m::mock(Builder::class); $connection->expects('table')->with('table')->andReturn($query); @@ -327,7 +312,7 @@ public function testPushIncludesBatchIdInPayloadForBatchableJob(): void public function testPushUsesPropertiesDeclaredOnChildClassOverInheritedAttributes(): void { $resolver = m::mock(ConnectionResolverInterface::class); - $database = m::mock(ConnectionInterface::class); + $database = m::mock(Connection::class); $resolver->expects('connection')->with(null)->andReturn($database); $queue = new DatabaseQueue($resolver, null, 'table', 'default'); $queue->setContainer($container = m::spy(Container::class)->makePartial()); @@ -354,7 +339,7 @@ public function testPushUsesPropertiesDeclaredOnChildClassOverInheritedAttribute public function testPushStillUsesAttributesDeclaredOnSameClassOverDefaultProperties(): void { $resolver = m::mock(ConnectionResolverInterface::class); - $database = m::mock(ConnectionInterface::class); + $database = m::mock(Connection::class); $resolver->expects('connection')->with(null)->andReturn($database); $queue = new DatabaseQueue($resolver, null, 'table', 'default'); $queue->setContainer($container = m::spy(Container::class)->makePartial()); @@ -428,7 +413,7 @@ public function testBulkBatchPushesOntoDatabase(): void availableAt: 1732502704, ); $queue->setContainer(new Container); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $query = m::mock(Builder::class); $connection->expects('table')->with('table')->andReturn($query); $resolver->expects('connection')->andReturn($connection); @@ -514,7 +499,7 @@ public function testBulkTreatsAnExplicitFalseInsertAsFailure(): void ); $queue->setContainer(new Container); - $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection = m::mock(ConnectionInterface::class)); + $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection = m::mock(Connection::class)); $connection->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insert')->once()->andReturnFalse(); @@ -582,7 +567,7 @@ public function testBulkAcceptsEveryDispatchBeforeRaisingSuccessEvents(): void $exception = new RuntimeException('Listener failed.'); $notified = []; - $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection = m::mock(ConnectionInterface::class)); + $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection = m::mock(Connection::class)); $connection->shouldReceive('transactionLevel')->twice()->andReturn(0); $connection->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insert')->once()->andReturnTrue(); @@ -617,7 +602,7 @@ public function testDelayAttributeIsRespectedWhenBulkPushing(): void currentTime: 1732502704, ); $queue->setContainer(new Container); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $resolver->expects('connection')->andReturn($connection); $query = m::mock(Builder::class); $connection->expects('table')->with('table')->andReturn($query); @@ -645,7 +630,7 @@ public function testBulkRaisesExactSuccessEventsAroundOneInsert(): void $queue->setContainer($container); $queue->setConnectionName('database'); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once(); $query->shouldReceive('insert')->once()->andReturnUsing(function (array $records): bool { @@ -700,7 +685,7 @@ public function testBulkRaisesExactFailureEventsWhenInsertThrows(): void $queue->setConnectionName('database'); $exception = new RuntimeException('Insert failed.'); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once(); $query->shouldReceive('insert')->once()->andThrow($exception); @@ -753,7 +738,7 @@ public function testBulkInsertFailureListenerErrorReporting(string $reporting): $queue->setConnectionName('database'); $exception = new RuntimeException('Insert failed.'); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once(); $query->shouldReceive('insert')->once()->andThrow($exception); @@ -876,7 +861,7 @@ public function testBulkCancellationRaisesCanceledEventsInsteadOfFailureEvents(b $queue->setContainer($container); $queue->setConnectionName('database'); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once(); $gate = $this->armCurrentCoroutineCancellation(); @@ -959,7 +944,7 @@ public function testBulkSplitsImmediateAndAfterCommitJobsAndReacquiresTheQueue() $queue->setContainer($container); $deferredQueue->setContainer(new Container); - $immediateConnection = m::mock(ConnectionInterface::class); + $immediateConnection = m::mock(Connection::class); $immediateConnection->shouldReceive('table')->with('table')->andReturn($immediateQuery = m::mock(Builder::class)); $immediateResolver->shouldReceive('connection')->with(null)->andReturn($immediateConnection)->once(); $immediateRecords = []; @@ -969,7 +954,7 @@ public function testBulkSplitsImmediateAndAfterCommitJobsAndReacquiresTheQueue() return true; }); - $deferredConnection = m::mock(ConnectionInterface::class); + $deferredConnection = m::mock(Connection::class); $deferredConnection->shouldReceive('table')->with('table')->andReturn($deferredQuery = m::mock(Builder::class)); $deferredResolver->shouldReceive('connection')->with(null)->andReturn($deferredConnection)->once(); $deferredRecords = []; @@ -1048,7 +1033,7 @@ public function testBulkDefersAfterCommitJobsUntilTheTransactionCommits(): void $container->instance('db.transactions', $transactions); $resolver = m::mock(ConnectionResolverInterface::class); - $database = m::mock(ConnectionInterface::class); + $database = m::mock(Connection::class); $resolver->expects('connection')->with(null)->andReturn($database); $queue = new DatabaseQueue($resolver, null, 'table', 'default'); $queue->setContainer($container); @@ -1225,7 +1210,7 @@ public function testAllReservedJobs(): void public function testTotalSize(): void { $resolver = m::mock(ConnectionResolverInterface::class); - $database = m::mock(ConnectionInterface::class); + $database = m::mock(Connection::class); $resolver->expects('connection')->with(null)->andReturn($database); $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); $queue->setContainer(new Container); @@ -1240,7 +1225,7 @@ public function testTotalSize(): void public function testTotalPendingSize(): void { $resolver = m::mock(ConnectionResolverInterface::class); - $database = m::mock(ConnectionInterface::class); + $database = m::mock(Connection::class); $resolver->expects('connection')->with(null)->andReturn($database); $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); $queue->setContainer(new Container); @@ -1257,7 +1242,7 @@ public function testTotalPendingSize(): void public function testTotalDelayedSize(): void { $resolver = m::mock(ConnectionResolverInterface::class); - $database = m::mock(ConnectionInterface::class); + $database = m::mock(Connection::class); $resolver->expects('connection')->with(null)->andReturn($database); $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); $queue->setContainer(new Container); @@ -1274,7 +1259,7 @@ public function testTotalDelayedSize(): void public function testTotalReservedSize(): void { $resolver = m::mock(ConnectionResolverInterface::class); - $database = m::mock(ConnectionInterface::class); + $database = m::mock(Connection::class); $resolver->expects('connection')->with(null)->andReturn($database); $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); $queue->setContainer(new Container); @@ -1334,7 +1319,7 @@ public function testInvalidInspectedPayloadIdentifiesItsQueueAndRecord(): void private function createFailingReservationQueue(Throwable $failure): array { $resolver = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $resolver->shouldReceive('connection')->with(null)->andReturn($connection); $connection->shouldReceive('transactionLevel')->andReturn(0); $connection->shouldReceive('transaction')->once()->andReturnUsing(static fn (Closure $callback): mixed => $callback()); @@ -1364,7 +1349,7 @@ private function createFailingReservationQueue(Throwable $failure): array private function createInspectionQueue(): array { $resolver = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $query = m::mock(Builder::class); $resolver->expects('connection')->with(null)->andReturn($connection); $connection->expects('table')->with('table')->andReturn($query); diff --git a/tests/Queue/QueueJobTest.php b/tests/Queue/QueueJobTest.php index fb4c56477..cb44a8c1b 100644 --- a/tests/Queue/QueueJobTest.php +++ b/tests/Queue/QueueJobTest.php @@ -10,7 +10,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Coroutine\Coroutine; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine as EngineCoroutine; @@ -282,7 +282,7 @@ public function testBatchRollbackFailurePreservesTimeoutForFailedJobCleanup(?str ], ], ]); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connection->shouldReceive('rollBack')->once()->with(0); $database = m::mock(ConnectionResolverInterface::class); $database->shouldReceive('connection')->once()->with($databaseName)->andReturn($connection); diff --git a/tests/RateLimiter/DatabaseStoreTest.php b/tests/RateLimiter/DatabaseStoreTest.php index cc31e81b3..4970c2ed5 100644 --- a/tests/RateLimiter/DatabaseStoreTest.php +++ b/tests/RateLimiter/DatabaseStoreTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\RateLimiter; use Closure; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Query\Builder; use Hypervel\RateLimiter\Backoff; @@ -22,7 +22,7 @@ class DatabaseStoreTest extends TestCase public function testInspectionUsesTheConfiguredConnectionTableAndWritePdo(): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $query = m::mock(Builder::class); $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); @@ -50,7 +50,7 @@ public function testInspectionUsesTheConfiguredConnectionTableAndWritePdo(): voi public function testEstablishedNonSqlMutationLocksBeforeReadingServerTimeWithoutInserting(): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $locked = m::mock(Builder::class); $update = m::mock(Builder::class); $operations = []; @@ -123,7 +123,7 @@ public function testMissingNonSqliteStateIsInitializedAndMutatedInASecondTransac string $clockQuery, ): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $missing = m::mock(Builder::class); $initialize = m::mock(Builder::class); $locked = m::mock(Builder::class); @@ -274,7 +274,7 @@ public static function nonSqliteDrivers(): array public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $insert = m::mock(Builder::class); $locked = m::mock(Builder::class); $update = m::mock(Builder::class); @@ -345,7 +345,7 @@ public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void public function testMutatingOperationsRejectUnsupportedPostgresIsolation(string $operation): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); @@ -379,7 +379,7 @@ public function testMutatingOperationsRejectUnsupportedPostgresIsolation(string public function testMutationRejectsNonStringPostgresIsolation(): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); @@ -399,7 +399,7 @@ public function testMutationRejectsNonStringPostgresIsolation(): void public function testPostgresMutationAllowsDefaultIsolation(): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $query = m::mock(Builder::class); $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); @@ -419,7 +419,7 @@ public function testPostgresMutationAllowsDefaultIsolation(): void public function testMysqlMutationDoesNotInspectPostgresIsolationConfiguration(): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $query = m::mock(Builder::class); $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); @@ -439,7 +439,7 @@ public function testMysqlMutationDoesNotInspectPostgresIsolationConfiguration(): public function testMutatingOperationsRejectAnActiveTransactionBeforeLimiterSql(string $operation): void { $connections = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(Connection::class); $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); $connection->shouldReceive('transactionLevel')->once()->andReturn(1); diff --git a/tests/Support/DateFacadeTest.php b/tests/Support/DateFacadeTest.php index a199d6852..e359df630 100644 --- a/tests/Support/DateFacadeTest.php +++ b/tests/Support/DateFacadeTest.php @@ -11,6 +11,7 @@ use Carbon\FactoryImmutable; use DateTime; use DateTimeInterface; +use Hypervel\Container\Container; use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\DateFactory; @@ -45,6 +46,15 @@ public function testDefaultDateRoutesReturnHypervelCarbonImmutable(): void self::assertBetweenStartAndNow($start, Date::now()->getTimestamp()); } + public function testUnboundDateUsesTheDefaultFactoryWithoutBindingIt(): void + { + $container = new Container; + Date::setFacadeApplication($container); + + $this->assertSame(DateFactory::class, Date::getFacadeRoot()::class); + $this->assertFalse($container->bound('date')); + } + public function testMutableClassIsAnExplicitOptOutAcrossDateRoutes(): void { DateFactory::use(Carbon::class); diff --git a/tests/Support/SupportFacadeNonCoroutineTest.php b/tests/Support/SupportFacadeNonCoroutineTest.php new file mode 100644 index 000000000..6ef924a6b --- /dev/null +++ b/tests/Support/SupportFacadeNonCoroutineTest.php @@ -0,0 +1,59 @@ +bind('foo', static fn (): stdClass => new stdClass); + NonCoroutineFacadeStub::setFacadeApplication($app); + + // Boot code resolves outside any coroutine; requests must not inherit that root. + $bootRoot = NonCoroutineFacadeStub::getFacadeRoot(); + $this->assertSame($bootRoot, NonCoroutineFacadeStub::getFacadeRoot()); + + $resolve = static function (): array { + $root = NonCoroutineFacadeStub::getFacadeRoot(); + usleep(1000); + + return [$root, NonCoroutineFacadeStub::getFacadeRoot()]; + }; + + run(static function () use (&$roots, $resolve): void { + $roots = parallel([$resolve, $resolve]); + }); + + [[$first, $firstAgain], [$second, $secondAgain]] = $roots; + + $this->assertSame($first, $firstAgain); + $this->assertSame($second, $secondAgain); + $this->assertNotSame($first, $second); + $this->assertNotSame($bootRoot, $first); + $this->assertNotSame($bootRoot, $second); + } +} + +class NonCoroutineFacadeStub extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'foo'; + } +} diff --git a/tests/Support/SupportFacadeTest.php b/tests/Support/SupportFacadeTest.php index 13b280fc9..522b71449 100755 --- a/tests/Support/SupportFacadeTest.php +++ b/tests/Support/SupportFacadeTest.php @@ -7,7 +7,9 @@ use Closure; use Hypervel\Container\Attributes\Scoped; use Hypervel\Container\Container; +use Hypervel\Context\CoroutineContext; use Hypervel\Context\ReplicableContext; +use Hypervel\Engine\Channel; use Hypervel\Support\Facades\Facade; use Hypervel\Support\Testing\Fakes\Fake; use Hypervel\Tests\TestCase; @@ -287,6 +289,110 @@ public function testFacadeKeepsReplicatedScopedObjectsInTheirCopiedContexts(): v $this->assertSame(FacadeStub::getFacadeRoot(), FacadeStub::getFacadeRoot()); } + public function testFacadeKeepsEachCoroutinesSelectionFromABoundFactory(): void + { + $app = new Container; + $app->singleton('first', static fn (): stdClass => new stdClass); + $app->singleton('second', static fn (): stdClass => new stdClass); + $app->bind('foo', static fn (Container $app): stdClass => $app->make(CoroutineContext::get('facade_test.selection'))); + FacadeStub::setFacadeApplication($app); + + $resolve = static function (string $selection): array { + CoroutineContext::set('facade_test.selection', $selection); + $root = FacadeStub::getFacadeRoot(); + usleep(1000); + + return [$root, FacadeStub::getFacadeRoot()]; + }; + + [[$first, $firstAgain], [$second, $secondAgain]] = parallel([ + static fn (): array => $resolve('first'), + static fn (): array => $resolve('second'), + ]); + + $this->assertSame($app->make('first'), $first); + $this->assertSame($first, $firstAgain); + $this->assertSame($app->make('second'), $second); + $this->assertSame($second, $secondAgain); + } + + public function testCopiedChildCoroutineResolvesItsOwnFacadeRoot(): void + { + $app = new Container; + $app->bind('foo', static fn (): stdClass => new stdClass); + FacadeStub::setFacadeApplication($app); + $parent = FacadeStub::getFacadeRoot(); + + [$child] = parallel([static fn (): stdClass => FacadeStub::getFacadeRoot()], copyContext: true); + + $this->assertNotSame($parent, $child); + $this->assertSame($parent, FacadeStub::getFacadeRoot()); + } + + #[DataProvider('facadeClears')] + public function testClearingFacadeRootsResolvesAgainInLiveCoroutines(Closure $clear): void + { + $app = new Container; + $app->bind('foo', static fn (): stdClass => new stdClass); + FacadeStub::setFacadeApplication($app); + + // Both coroutines resolve before the clear and resolve again only after it. A bounded wait keeps a failure + // in one coroutine from leaving the others waiting. + $resolved = new Channel(2); + $cleared = new Channel(2); + + $resolve = function () use ($resolved, $cleared): array { + $before = FacadeStub::getFacadeRoot(); + $resolved->push(true); + $this->assertTrue($cleared->pop(5.0)); + + return [$before, FacadeStub::getFacadeRoot()]; + }; + + [[$firstBefore, $firstAfter], [$secondBefore, $secondAfter]] = parallel([ + $resolve, + $resolve, + function () use ($clear, $resolved, $cleared): void { + $this->assertTrue($resolved->pop(5.0)); + $this->assertTrue($resolved->pop(5.0)); + $clear(); + $cleared->push(true); + $cleared->push(true); + }, + ]); + + $this->assertNotSame($firstBefore, $firstAfter); + $this->assertNotSame($secondBefore, $secondAfter); + } + + /** + * Provide the ways to clear resolved facade roots. + */ + public static function facadeClears(): iterable + { + yield 'one accessor' => [static function (): void { + FacadeStub::clearResolvedInstance(); + }]; + + yield 'all accessors' => [static function (): void { + Facade::clearResolvedInstances(); + }]; + } + + public function testUncachedFacadeIgnoresTheCoroutineRootOfACachedFacade(): void + { + $app = new Container; + $app->bind('foo', static fn (): stdClass => new stdClass); + FacadeStub::setFacadeApplication($app); + + $cached = FacadeStub::getFacadeRoot(); + $uncached = UncachedFooFacadeStub::getFacadeRoot(); + + $this->assertNotSame($cached, $uncached); + $this->assertNotSame($uncached, UncachedFooFacadeStub::getFacadeRoot()); + $this->assertSame($cached, FacadeStub::getFacadeRoot()); + } + public function testCachedFacadeResolvesOnce(): void { $app = new CountingApplicationStub; @@ -374,6 +480,11 @@ protected static function getFacadeAccessor(): string } } +class UncachedFooFacadeStub extends FacadeStub +{ + protected static bool $cached = false; +} + class CountingApplicationStub extends ApplicationStub { public int $makeCount = 0; diff --git a/tests/Testbench/BootstrapperTest.php b/tests/Testbench/BootstrapperTest.php index d4428989f..152b264a7 100644 --- a/tests/Testbench/BootstrapperTest.php +++ b/tests/Testbench/BootstrapperTest.php @@ -420,6 +420,7 @@ public function itLinksPackageDependenciesWhenTheSkeletonHasNoVendorDirectory(): $this->assertTrue(is_link($runtimePath . '/vendor')); $this->assertSame(realpath($packagePath . '/vendor'), realpath($runtimePath . '/vendor')); + $this->assertSame(readlink($runtimePath . '/vendor'), Bootstrapper::runtimeVendorPath()); $this->assertFileExists($runtimePath . '/vendor/dependency/file.php'); $this->assertFalse(is_link($runtimePath . '/config')); $this->assertSame('config', file_get_contents($runtimePath . '/config/app.php')); @@ -1168,6 +1169,7 @@ private function withRuntimeCopyEnvironment(string $token, bool $packageTester, $previousProcessPackageTester = getenv('TESTBENCH_PACKAGE_TESTER'); $previousRuntimePath = $reflection->getStaticPropertyValue('runtimePath'); $previousRuntimeSourcePath = $reflection->getStaticPropertyValue('runtimeSourcePath'); + $previousRuntimeVendorPath = $reflection->getStaticPropertyValue('runtimeVendorPath'); try { $this->setTestToken($token); @@ -1184,6 +1186,7 @@ private function withRuntimeCopyEnvironment(string $token, bool $packageTester, $this->restorePackageTester($previousServerPackageTester, $previousEnvironmentPackageTester, $previousProcessPackageTester); $reflection->setStaticPropertyValue('runtimePath', $previousRuntimePath); $reflection->setStaticPropertyValue('runtimeSourcePath', $previousRuntimeSourcePath); + $reflection->setStaticPropertyValue('runtimeVendorPath', $previousRuntimeVendorPath); } } diff --git a/tests/Testbench/Concerns/CreatesApplicationTest.php b/tests/Testbench/Concerns/CreatesApplicationTest.php index 6405b9fe5..965b4d459 100644 --- a/tests/Testbench/Concerns/CreatesApplicationTest.php +++ b/tests/Testbench/Concerns/CreatesApplicationTest.php @@ -14,6 +14,7 @@ use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Http\Request; use Hypervel\RateLimiter\Limit; +use Hypervel\Support\DateFactory; use Hypervel\Support\Facades\App; use Hypervel\Support\Facades\RateLimiter; use Hypervel\Support\Facades\Route; @@ -69,6 +70,7 @@ protected function overrideApplicationBindings(ApplicationContract $app): array return [ FoundationLoadConfiguration::class => RecordingLoadConfiguration::class, FoundationPackageManifest::class => OverriddenPackageManifest::class, + 'date' => OverriddenDateFactory::class, ]; } @@ -119,6 +121,11 @@ public function testApplicationBindingOverridesReplaceThePackageManifest(): void $this->assertTrue($this->app->providerIsLoaded(ManifestTestServiceProvider::class)); } + public function testApplicationBindingOverridesKeepTheirDateFactory(): void + { + $this->assertSame(OverriddenDateFactory::class, $this->app->make('date')::class); + } + #[ResolvesHypervel('captureFacadeRootDuringConfiguration')] public function testFacadesResolveTheApplicationBeforeConfigurationLoads(): void { @@ -328,6 +335,10 @@ public function aliases(): array } } +class OverriddenDateFactory extends DateFactory +{ +} + class ManifestTestServiceProvider extends ServiceProvider { } diff --git a/tests/Validation/ValidationDatabasePresenceVerifierTest.php b/tests/Validation/ValidationDatabasePresenceVerifierTest.php index 59503214d..28034eacd 100644 --- a/tests/Validation/ValidationDatabasePresenceVerifierTest.php +++ b/tests/Validation/ValidationDatabasePresenceVerifierTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Validation; use Closure; -use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Query\Builder; use Hypervel\Support\Collection; @@ -21,7 +21,7 @@ public function testBasicCount(): void $db = m::mock(ConnectionResolverInterface::class); $verifier = new DatabasePresenceVerifier($db); $verifier->setConnection('connection'); - $conn = m::mock(ConnectionInterface::class); + $conn = m::mock(Connection::class); $db->expects('connection')->with('connection')->andReturn($conn); $builder = m::mock(Builder::class); $conn->expects('table')->with('table')->andReturn($builder); @@ -43,7 +43,7 @@ public function testBasicCountWithClosures(): void $db = m::mock(ConnectionResolverInterface::class); $verifier = new DatabasePresenceVerifier($db); $verifier->setConnection('connection'); - $conn = m::mock(ConnectionInterface::class); + $conn = m::mock(Connection::class); $db->expects('connection')->with('connection')->andReturn($conn); $builder = m::mock(Builder::class); $conn->expects('table')->with('table')->andReturn($builder); @@ -74,7 +74,7 @@ public function testGetCountWithValidExcludeId(): void $db = m::mock(ConnectionResolverInterface::class); $verifier = new DatabasePresenceVerifier($db); $verifier->setConnection('connection'); - $conn = m::mock(ConnectionInterface::class); + $conn = m::mock(Connection::class); $db->expects('connection')->with('connection')->andReturn($conn); $builder = m::mock(Builder::class); $conn->expects('table')->with('table')->andReturn($builder); @@ -91,7 +91,7 @@ public function testGetExistingValuesUsesRequestedConnectionAndReturnsDistinctVa { $verifier = new DatabasePresenceVerifier($db = m::mock(ConnectionResolverInterface::class)); $verifier->setConnection('stateful-connection'); - $db->expects('connection')->with($connection)->andReturn($database = m::mock(ConnectionInterface::class)); + $db->expects('connection')->with($connection)->andReturn($database = m::mock(Connection::class)); $database->expects('table')->with('table')->andReturn($builder = m::mock(Builder::class)); $builder->expects('useWritePdo')->andReturnSelf(); $builder->expects('whereIn')->with('column', ['first', 'second'])->andReturnSelf(); diff --git a/types/Database/Eloquent/Casts/Attribute.php b/types/Database/Eloquent/Casts/Attribute.php index 2f9e24378..b72cc707f 100644 --- a/types/Database/Eloquent/Casts/Attribute.php +++ b/types/Database/Eloquent/Casts/Attribute.php @@ -4,7 +4,7 @@ namespace Hypervel\Types\Attribute; -use Carbon\Carbon; +use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Hypervel\Database\Eloquent\Casts\Attribute; @@ -17,7 +17,7 @@ */ function widerDeclaration(): Attribute { - return Attribute::make(fn () => new Carbon, fn (string|int|null $value) => $value); + return Attribute::make(fn () => new CarbonImmutable, fn (string|int|null $value) => $value); } assertType('Hypervel\Database\Eloquent\Casts\Attribute', Attribute::make( diff --git a/types/Filesystem/Filesystem.php b/types/Filesystem/Filesystem.php new file mode 100644 index 000000000..3c05cb1ac --- /dev/null +++ b/types/Filesystem/Filesystem.php @@ -0,0 +1,29 @@ +withClient(fn (S3Client $client) => $client->listBuckets())); + $pooled->withClient(fn ($client) => assertType('mixed', $client)); + + foreach ([$pooled, $disk] as $filesystem) { + assertType('bool', $filesystem->withAdapter(fn ($adapter) => $adapter->fileExists('file.txt'))); + assertType(AwsS3V3Adapter::class, $filesystem->withAdapter(fn (AwsS3V3Adapter $adapter) => $adapter)); + assertType('bool', $filesystem->withDriver(fn ($driver) => $driver->fileExists('file.txt'))); + assertType('string', $filesystem->withDriver(fn (Filesystem $driver) => $driver->publicUrl('file.txt'))); + } +}