diff --git a/README.md b/README.md index e2e021d..d010e67 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ discovered: | `final` methods stay | a final method cannot be overridden either way, so a class carrying one is still refused | | PHAR and preloaded classes | their source arrives as `phar://`, or before any bootstrap ran, so it never passes through the `file://` wrapper | | The opcode cache is not a way back | however warm the cache is, and whether or not it holds the bypassed file — Linux keeps it out, Windows does not — the class stays open. Not being cached is a cost where it happens, not a guarantee to rely on | -| Another source transformer | if something else is already rewriting PHP source, understudy refuses rather than replacing it silently; a wrapper that leaves source alone composes and is accepted | +| Another `file://` wrapper | if a user-space wrapper already owns `file://`, understudy refuses rather than replacing it silently, whether or not that wrapper transforms PHP source | When a class is still final at `for()`, the refusal says which of these it was — bypass never asked for, asked for other classes but not this one, or asked for diff --git a/README.ru.md b/README.ru.md index 7290877..c04f8af 100644 --- a/README.ru.md +++ b/README.ru.md @@ -253,7 +253,7 @@ Understudy::bypassFinals(); // все, что процесс | `final`-методы остаются | такой метод не переопределить в любом случае, поэтому класс с ним по-прежнему отвергается | | PHAR и preload | исходник приходит как `phar://` либо раньше любого bootstrap, поэтому через `file://`-враппер не проходит вовсе | | Opcode-кэш не возвращает запечатанный класс | насколько бы тёплым кэш ни был и держит ли он bypass-нутый файл — Linux не держит, Windows держит — класс остаётся открытым. Отсутствие в кэше это цена там, где оно есть, а не гарантия, на которую стоит опираться | -| Чужой трансформер исходника | если PHP-исходник уже кто-то переписывает, understudy откажется, а не подменит молча; враппер, который исходник не трогает, композируется и принимается | +| Чужой `file://`-враппер | если `file://` уже обслуживает пользовательский враппер, understudy откажется, а не подменит его молча — независимо от того, меняет он PHP-исходник или нет | Если класс всё ещё final в момент `for()`, отказ говорит, какой именно это случай — bypass не включали вовсе, включили для других классов, но не для diff --git a/docs/.api-workspace/composer.lock b/docs/.api-workspace/composer.lock index 93ec354..7ceefe0 100644 --- a/docs/.api-workspace/composer.lock +++ b/docs/.api-workspace/composer.lock @@ -3100,7 +3100,7 @@ "dist": { "type": "path", "url": "../..", - "reference": "d044131323553ca7b6ecb639e9bb41d2ba10e1d7" + "reference": "789f7756b3c9bb01b1c8e0a7e382f96baeba30de" }, "require": { "ext-tokenizer": "*", @@ -3234,16 +3234,16 @@ }, { "name": "rasuvaeff/understudy-phpstan", - "version": "v1.0.0", + "version": "v1.0.1", "source": { "type": "git", "url": "https://github.com/rasuvaeff/understudy-phpstan.git", - "reference": "6d3fb1ca06d092dc613881efd6fe66bafa970914" + "reference": "b002d987101830bf69bc35d74a64b63ea3627063" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rasuvaeff/understudy-phpstan/zipball/6d3fb1ca06d092dc613881efd6fe66bafa970914", - "reference": "6d3fb1ca06d092dc613881efd6fe66bafa970914", + "url": "https://api.github.com/repos/rasuvaeff/understudy-phpstan/zipball/b002d987101830bf69bc35d74a64b63ea3627063", + "reference": "b002d987101830bf69bc35d74a64b63ea3627063", "shasum": "" }, "require": { @@ -3264,7 +3264,7 @@ "testo/testo": "^0.10.39", "vimeo/psalm": "^6.16" }, - "type": "library", + "type": "phpstan-extension", "extra": { "phpstan": { "includes": [ @@ -3302,7 +3302,7 @@ "issues": "https://github.com/rasuvaeff/understudy-phpstan/issues", "source": "https://github.com/rasuvaeff/understudy-phpstan" }, - "time": "2026-09-27T12:56:43+00:00" + "time": "2026-09-27T19:06:33+00:00" }, { "name": "rasuvaeff/understudy-phpunit", diff --git a/docs/scripts/rules-snapshot.json b/docs/scripts/rules-snapshot.json index 0b75b7a..e14f5bb 100644 --- a/docs/scripts/rules-snapshot.json +++ b/docs/scripts/rules-snapshot.json @@ -1,8 +1,8 @@ { "phpstan": { - "version": "v1.0.0", - "reference": "6d3fb1ca06d092dc613881efd6fe66bafa970914", - "repoBlob": "https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/", + "version": "v1.0.1", + "reference": "b002d987101830bf69bc35d74a64b63ea3627063", + "repoBlob": "https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/", "registeredRules": [ "Rasuvaeff\\Understudy\\PhpStan\\Rule\\SpecificationCallRule", "Rasuvaeff\\Understudy\\PhpStan\\Rule\\SpecificationStaticCallRule", diff --git a/docs/src/api/rules.md b/docs/src/api/rules.md index 1b8ce63..ab9dfd2 100644 --- a/docs/src/api/rules.md +++ b/docs/src/api/rules.md @@ -9,15 +9,15 @@ description: "Every identifier the PHPStan extension reports and every issue typ Neither analyser package has an `@api` class, and that is correct rather than a missing annotation: `understudy-phpstan` is registered through `extension.neon` and `understudy-psalm` through `psalm-plugin enable`, so a user never names one of their classes. What a user does write is an **identifier**, in `ignoreErrors` or a suppression. That is the contract, so that is what is reflected here. -## PHPStan · `rasuvaeff/understudy-phpstan` v1.0.0 +## PHPStan · `rasuvaeff/understudy-phpstan` v1.0.1 | Identifier | Reported by | Summary | |---|---|---| -| `understudy.cardinality` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. | -| `understudy.closure` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. | -| `understudy.matcher` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. | -| `understudy.matcherLeak` | [`MatcherLeakRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Rule/MatcherLeakRule.php) | A matcher written outside a specification. | -| `understudy.returns` | [`VoidReturnsRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Rule/VoidReturnsRule.php) | `returns()` on a method that returns nothing. | +| `understudy.cardinality` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. | +| `understudy.closure` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. | +| `understudy.matcher` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. | +| `understudy.matcherLeak` | [`MatcherLeakRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Rule/MatcherLeakRule.php) | A matcher written outside a specification. | +| `understudy.returns` | [`VoidReturnsRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Rule/VoidReturnsRule.php) | `returns()` on a method that returns nothing. | Silence one by its identifier: diff --git a/docs/src/guide/doubles/final-classes.md b/docs/src/guide/doubles/final-classes.md index 2cdb8db..e951ce8 100644 --- a/docs/src/guide/doubles/final-classes.md +++ b/docs/src/guide/doubles/final-classes.md @@ -26,7 +26,7 @@ than discovered. | `final` methods stay | a final method cannot be overridden either way, so a class carrying one is still refused | | PHAR and preloaded classes | their source arrives as `phar://`, or before any bootstrap ran, so it never passes through the `file://` wrapper | | The opcode cache is not a way back | however warm the cache is, and whether or not it holds the bypassed file — Linux keeps it out, Windows does not — the class stays open. Not being cached is a cost where it happens, not a guarantee to rely on | -| Another source transformer | if something else is already rewriting PHP source, understudy refuses rather than replacing it silently; a wrapper that leaves source alone composes and is accepted | +| Another `file://` wrapper | if a user-space wrapper already owns `file://`, understudy refuses rather than replacing it silently, whether or not that wrapper transforms PHP source | ## The refusal tells you which one it was diff --git a/docs/src/guide/security.md b/docs/src/guide/security.md index 6168dc1..7ce1c4c 100644 --- a/docs/src/guide/security.md +++ b/docs/src/guide/security.md @@ -36,5 +36,5 @@ Do not install it in production. `composer require --dev`. [`bypassFinals()`](/guide/doubles/final-classes) rewrites source as it is read, so a class really is not final any more for the rest of the process. It is -opt-in for exactly that reason, and it refuses to act rather than to compete -when another source transformer is already installed. +opt-in for exactly that reason, and it refuses to act rather than replace any +user-space wrapper that already owns `file://`. diff --git a/src/Exception/BypassUnavailable.php b/src/Exception/BypassUnavailable.php index 89e52ff..ebad78c 100644 --- a/src/Exception/BypassUnavailable.php +++ b/src/Exception/BypassUnavailable.php @@ -43,15 +43,15 @@ public static function notAClass(string $class, string $kind): self } /** - * Builds the error for an already-installed foreign source wrapper. + * Builds the error for an already-installed user-space file wrapper. * - * Something else already transforms PHP source on `file://`; replacing it - * would silently disable whatever it does. + * Something else owns `file://`; replacing it would silently disable + * whatever it does, whether or not it transforms PHP source. */ public static function foreignWrapper(string $owner): self { return new self(sprintf( - "Something else is already transforming PHP source read through `file://` (%s).\n" + "Something else is already serving `file://` through a user-space wrapper (%s).\n" . "Replacing that wrapper would silently disable whatever it does, so understudy will not.\n" . "- Call Understudy::bypassFinals() before the other wrapper is installed.\n" . '- Or double an interface, which needs no wrapper at all.', diff --git a/src/Understudy.php b/src/Understudy.php index d3cba4a..5da5dd1 100644 --- a/src/Understudy.php +++ b/src/Understudy.php @@ -733,8 +733,8 @@ public static function wire(string $sut, array $overrides = []): array */ public static function bypassFinals(?string $class = null): void { - if (!FileWrapper::isInstalled() && self::foreignSourceTransform()) { - throw BypassUnavailable::foreignWrapper('the source it read back was not the source on disk'); + if (!FileWrapper::isInstalled() && self::foreignFileWrapper()) { + throw BypassUnavailable::foreignWrapper('a user-space wrapper already owns `file://`'); } if ($class === null) { @@ -769,20 +769,12 @@ public static function bypassFinals(?string $class = null): void } /** - * Whether something already transforms PHP source read through `file://`. - * - * PHP exposes no owner for a protocol — `stream_get_wrappers()` lists - * `file` whoever handles it, and every register/restore call answers `true` - * either way — so the question is asked of behaviour instead. The stripper's - * own file declares `final class FinalStripper`, and a wrapper that strips - * `final` from class declarations, which is precisely the incompatible one, - * will have removed it by the time the bytes arrive here. - * - * What it catches: another source transformer. What it does not: a wrapper - * that leaves PHP source alone, which by definition composes with this one - * anyway. Not a guarantee dressed up as one. + * Whether a user-space wrapper already owns `file://`. + * + * Any existing user-space wrapper may have behaviour that replacing it + * would silently disable, so bypass refuses before installing its wrapper. */ - private static function foreignSourceTransform(): bool + private static function foreignFileWrapper(): bool { $file = (new \ReflectionClass(FinalStripper::class))->getFileName(); @@ -790,15 +782,19 @@ private static function foreignSourceTransform(): bool return false; } - $source = @file_get_contents($file); + $stream = @fopen($file, 'r'); - if ($source === false) { - // Nothing readable to compare against; refusing here would fail a + if ($stream === false) { + // Nothing readable to inspect; refusing here would fail a // bypass for a reason that has nothing to do with wrappers. return false; } - return !str_contains($source, 'final class FinalStripper'); + try { + return (stream_get_meta_data($stream)['wrapper_type'] ?? null) === 'user-space'; + } finally { + fclose($stream); + } } /** diff --git a/tests/Fixture/Bypass/PassiveWrapper.php b/tests/Fixture/Bypass/PassiveWrapper.php index c16ce51..c38e0a5 100644 --- a/tests/Fixture/Bypass/PassiveWrapper.php +++ b/tests/Fixture/Bypass/PassiveWrapper.php @@ -7,10 +7,9 @@ /** * Somebody else owning `file://` without touching PHP source. * - * The refusal is deliberately narrow — it asks whether the source read back is - * the source on disk, not whether anyone else is present — so this one has to - * be *accepted*. Pinning that is what keeps the narrowness a decision rather - * than a gap nobody noticed. + * Understudy still refuses it: it cannot establish that replacing an existing + * user-space wrapper would be safe just because this fixture leaves source + * text alone. */ final class PassiveWrapper { diff --git a/tests/Fixture/Bypass/scenario.php b/tests/Fixture/Bypass/scenario.php index c2c6704..c103c5f 100644 --- a/tests/Fixture/Bypass/scenario.php +++ b/tests/Fixture/Bypass/scenario.php @@ -272,7 +272,7 @@ class_exists(\Rasuvaeff\Understudy\Bypass\FinalStripper::class); return 'installed over another stripper'; } catch (BypassUnavailable $refused) { - return str_contains($refused->getMessage(), 'the source it read back was not the source on disk') + return str_contains($refused->getMessage(), 'a user-space wrapper already owns `file://`') ? 'refused' : 'refused with: ' . $refused->getMessage(); } finally { @@ -287,9 +287,11 @@ class_exists(\Rasuvaeff\Understudy\Bypass\FinalStripper::class); try { Understudy::bypassFinals(SealedGate::class); - return 'accepted'; + return 'installed over another wrapper'; } catch (BypassUnavailable $refused) { - return 'refused: ' . $refused->getMessage(); + return str_contains($refused->getMessage(), 'a user-space wrapper already owns `file://`') + ? 'refused' + : 'refused with: ' . $refused->getMessage(); } finally { stream_wrapper_restore('file'); } diff --git a/tests/Integration/BypassFinalsIntegrationTest.php b/tests/Integration/BypassFinalsIntegrationTest.php index 4b98830..87f64f6 100644 --- a/tests/Integration/BypassFinalsIntegrationTest.php +++ b/tests/Integration/BypassFinalsIntegrationTest.php @@ -58,10 +58,7 @@ public static function scenarioProvider(): iterable 'opened, line 19', ]; yield 'a second final-stripper on file:// is refused' => ['foreign-stripper', 'refused']; - // Deliberately the other way round: the refusal asks whether the - // source read back is the source on disk, not whether anyone else is - // there. A wrapper that leaves PHP source alone composes. - yield 'a wrapper that leaves source alone is accepted' => ['passive-wrapper', 'accepted']; + yield 'a wrapper that leaves source alone is also refused' => ['passive-wrapper', 'refused']; yield 'a class nobody named stays final, and the refusal says so' => [ 'bypass-for-another-class', 'refused, naming the omission', diff --git a/tests/Support/DispatchFindCommand.php b/tests/Support/DispatchFindCommand.php index 972176c..57f9586 100644 --- a/tests/Support/DispatchFindCommand.php +++ b/tests/Support/DispatchFindCommand.php @@ -56,7 +56,7 @@ public function run(mixed $model, mixed $system): array } return [ - $answer === null ? null : $answer->title, + $answer?->title, count(Understudy::calls(fn() => $system->double->find(Arg::any()))), ]; }