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..eeaed2e --- /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().', + 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().', + 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').", + 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().', + 47, + ], + ]); + } +} diff --git a/tests/PHPStan/data/panicking-unwrap.php b/tests/PHPStan/data/panicking-unwrap.php new file mode 100644 index 0000000..7501297 --- /dev/null +++ b/tests/PHPStan/data/panicking-unwrap.php @@ -0,0 +1,47 @@ + $result */ +$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 + +/** @var Err $err */ +fn() => $err->unwrap(); // flagged: narrowed Err + +/** @var Option $option */ +$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 + +/** @var None $none */ +fn() => $none->unwrap(); // flagged: narrowed None