From 61e57e22d56332bfe5e2e8ae353c7a1bfb8a09b9 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 1 Jun 2026 16:27:23 +0200 Subject: [PATCH 1/2] Add PHPStan extension banning panicking monad unwraps Ships a RestrictedMethodUsageExtension that flags the panicking escape-hatch methods on Result and Option: - Result::unwrap()/unwrapErr() and Option::unwrap() (may panic) - Err::unwrap(), Ok::unwrapErr(), None::unwrap() (always panic when the type is narrowed to the failing variant) Messages steer callers toward match(), expect()/expectErr(), unwrapOr()/unwrapOrElse() and mapOrElse(). Registered via extension.neon and composer's extra.phpstan.includes so consumers using phpstan/extension-installer pick it up automatically; the library's own analysis is unaffected. Includes a RuleTestCase covering every banned call and asserting safe calls are not flagged. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 7 +++ extension.neon | 5 ++ .../NoPanickingMonadUnwrapExtension.php | 45 +++++++++++++++ .../NoPanickingMonadUnwrapExtensionTest.php | 57 +++++++++++++++++++ tests/PHPStan/data/panicking-unwrap.php | 34 +++++++++++ 5 files changed, 148 insertions(+) create mode 100644 extension.neon create mode 100644 src/PHPStan/NoPanickingMonadUnwrapExtension.php create mode 100644 tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php create mode 100644 tests/PHPStan/data/panicking-unwrap.php diff --git a/composer.json b/composer.json index 569fc86..3167624 100644 --- a/composer.json +++ b/composer.json @@ -29,5 +29,12 @@ "allow-plugins": { "pestphp/pest-plugin": true } + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } } } diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..e5aa33d --- /dev/null +++ b/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Superscript\Monads\PHPStan\NoPanickingMonadUnwrapExtension + tags: + - phpstan.restrictedMethodUsageExtension diff --git a/src/PHPStan/NoPanickingMonadUnwrapExtension.php b/src/PHPStan/NoPanickingMonadUnwrapExtension.php new file mode 100644 index 0000000..b5bc184 --- /dev/null +++ b/src/PHPStan/NoPanickingMonadUnwrapExtension.php @@ -0,0 +1,45 @@ + [ + 'unwrap' => "Result::unwrap() throws when the Result is an Err. Handle both cases with match(), document the invariant with expect('why this cannot fail'), or supply a fallback via unwrapOr()/unwrapOrElse().", + 'unwrapErr' => "Result::unwrapErr() throws when the Result is an Ok. Handle both cases with match(), or document the invariant with expectErr('why this must be an Err').", + ], + 'Superscript\\Monads\\Result\\Err' => [ + 'unwrap' => 'This value is statically known to be an Err, so unwrap() will always throw. Read the error with unwrapErr(), or handle both branches with match().', + ], + 'Superscript\\Monads\\Result\\Ok' => [ + 'unwrapErr' => 'This value is statically known to be an Ok, so unwrapErr() will always throw. Read the value with unwrap(), or handle both branches with match().', + ], + 'Superscript\\Monads\\Option\\Option' => [ + 'unwrap' => "Option::unwrap() throws when the Option is None. Supply a fallback via unwrapOr()/unwrapOrElse(), collapse both cases with mapOrElse(), or document the invariant with expect('why this cannot be None').", + ], + 'Superscript\\Monads\\Option\\None' => [ + 'unwrap' => 'This value is statically known to be None, so unwrap() will always throw. Guard with isSome() first, or supply a fallback via unwrapOr()/unwrapOrElse().', + ], + ]; + + public function isRestrictedMethodUsage(ExtendedMethodReflection $methodReflection, Scope $scope): ?RestrictedUsage + { + $declaring = $methodReflection->getDeclaringClass()->getName(); + $name = $methodReflection->getName(); + + $message = self::BANNED[$declaring][$name] ?? null; + if ($message === null) { + return null; + } + + return RestrictedUsage::create(errorMessage: $message, identifier: 'monads.panickingUnwrap'); + } +} diff --git a/tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php b/tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php new file mode 100644 index 0000000..5c86d56 --- /dev/null +++ b/tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php @@ -0,0 +1,57 @@ + + */ +final class NoPanickingMonadUnwrapExtensionTest extends RuleTestCase +{ + protected function getRule(): Rule + { + // The rule reads RestrictedMethodUsageExtension services from the container by tag; + // getAdditionalConfigFiles() below loads extension.neon so ours is registered. + return new RestrictedMethodUsageRule(self::getContainer(), self::createReflectionProvider()); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../extension.neon']; + } + + public function testFlagsPanickingUnwraps(): void + { + $this->analyse([__DIR__ . '/data/panicking-unwrap.php'], [ + [ + "Result::unwrap() throws when the Result is an Err. Handle both cases with match(), document the invariant with expect('why this cannot fail'), or supply a fallback via unwrapOr()/unwrapOrElse().", + 15, + ], + [ + "Result::unwrapErr() throws when the Result is an Ok. Handle both cases with match(), or document the invariant with expectErr('why this must be an Err').", + 16, + ], + [ + 'This value is statically known to be an Ok, so unwrapErr() will always throw. Read the value with unwrap(), or handle both branches with match().', + 21, + ], + [ + 'This value is statically known to be an Err, so unwrap() will always throw. Read the error with unwrapErr(), or handle both branches with match().', + 24, + ], + [ + "Option::unwrap() throws when the Option is None. Supply a fallback via unwrapOr()/unwrapOrElse(), collapse both cases with mapOrElse(), or document the invariant with expect('why this cannot be None').", + 27, + ], + [ + 'This value is statically known to be None, so unwrap() will always throw. Guard with isSome() first, or supply a fallback via unwrapOr()/unwrapOrElse().', + 34, + ], + ]); + } +} diff --git a/tests/PHPStan/data/panicking-unwrap.php b/tests/PHPStan/data/panicking-unwrap.php new file mode 100644 index 0000000..57a4083 --- /dev/null +++ b/tests/PHPStan/data/panicking-unwrap.php @@ -0,0 +1,34 @@ + $result */ +$result->unwrap(); // flagged: Result::unwrap() +$result->unwrapErr(); // flagged: Result::unwrapErr() +$result->match(fn($e) => $e, fn($v) => $v); // NOT flagged + +/** @var Ok $ok */ +$ok->unwrap(); // NOT flagged: Ok::unwrap() is safe +fn () => $ok->unwrapErr(); // flagged: narrowed Ok + +/** @var Err $err */ +fn () => $err->unwrap(); // flagged: narrowed Err + +/** @var Option $option */ +$option->unwrap(); // flagged: Option::unwrap() +$option->unwrapOr(0); // NOT flagged + +/** @var Some $some */ +$some->unwrap(); // NOT flagged: Some::unwrap() is safe + +/** @var None $none */ +fn () => $none->unwrap(); // flagged: narrowed None From 261939cf1547f93af70288011cd0a47eb67ede97 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Mon, 1 Jun 2026 16:42:38 +0200 Subject: [PATCH 2/2] Add fixture cases proving type guards make unwrap safe After isOk()/isErr() (Result) or isSome() (Option), the value is narrowed to the safe variant, so unwrap()/unwrapErr() must not be flagged. The exhaustive RuleTestCase assertion enforces no errors are emitted for these guarded calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NoPanickingMonadUnwrapExtensionTest.php | 8 +++--- tests/PHPStan/data/panicking-unwrap.php | 27 ++++++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php b/tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php index 5c86d56..eeaed2e 100644 --- a/tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php +++ b/tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php @@ -38,19 +38,19 @@ public function testFlagsPanickingUnwraps(): void ], [ 'This value is statically known to be an Ok, so unwrapErr() will always throw. Read the value with unwrap(), or handle both branches with match().', - 21, + 30, ], [ 'This value is statically known to be an Err, so unwrap() will always throw. Read the error with unwrapErr(), or handle both branches with match().', - 24, + 33, ], [ "Option::unwrap() throws when the Option is None. Supply a fallback via unwrapOr()/unwrapOrElse(), collapse both cases with mapOrElse(), or document the invariant with expect('why this cannot be None').", - 27, + 36, ], [ 'This value is statically known to be None, so unwrap() will always throw. Guard with isSome() first, or supply a fallback via unwrapOr()/unwrapOrElse().', - 34, + 47, ], ]); } diff --git a/tests/PHPStan/data/panicking-unwrap.php b/tests/PHPStan/data/panicking-unwrap.php index 57a4083..7501297 100644 --- a/tests/PHPStan/data/panicking-unwrap.php +++ b/tests/PHPStan/data/panicking-unwrap.php @@ -12,23 +12,36 @@ use Superscript\Monads\Result\Result; /** @var Result $result */ -$result->unwrap(); // flagged: Result::unwrap() +$result->unwrap(); // flagged: Result::unwrap() $result->unwrapErr(); // flagged: Result::unwrapErr() $result->match(fn($e) => $e, fn($v) => $v); // NOT flagged +// After a type guard the value is narrowed to the safe variant, so unwrap is allowed. +if ($result->isOk()) { + $result->unwrap(); // NOT flagged: narrowed to Ok +} + +if ($result->isErr()) { + $result->unwrapErr(); // NOT flagged: narrowed to Err +} + /** @var Ok $ok */ -$ok->unwrap(); // NOT flagged: Ok::unwrap() is safe -fn () => $ok->unwrapErr(); // flagged: narrowed Ok +$ok->unwrap(); // NOT flagged: Ok::unwrap() is safe +fn() => $ok->unwrapErr(); // flagged: narrowed Ok /** @var Err $err */ -fn () => $err->unwrap(); // flagged: narrowed Err +fn() => $err->unwrap(); // flagged: narrowed Err /** @var Option $option */ -$option->unwrap(); // flagged: Option::unwrap() +$option->unwrap(); // flagged: Option::unwrap() $option->unwrapOr(0); // NOT flagged +if ($option->isSome()) { + $option->unwrap(); // NOT flagged: narrowed to Some +} + /** @var Some $some */ -$some->unwrap(); // NOT flagged: Some::unwrap() is safe +$some->unwrap(); // NOT flagged: Some::unwrap() is safe /** @var None $none */ -fn () => $none->unwrap(); // flagged: narrowed None +fn() => $none->unwrap(); // flagged: narrowed None