From 42adb79191b41fe901d8a52630783b583b616d7d Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Tue, 26 May 2026 09:56:42 +0200 Subject: [PATCH 1/2] Detect compose() boundary mismatches under covariant Iso/Lens templates After making Iso/Lens templates covariant (#9), Psalm no longer flagged compose() calls where adjacent isos/lenses don't line up (e.g. compose(Iso, Iso, Iso)) because covariant template inference silently widens the shared boundary to a union. Add a FunctionReturnTypeProvider hook on the existing ComposeProvider classes that walks adjacent args, extracts their TGenericObject template params, and emits InvalidArgument when arg[i]'s right template and arg[i+1]'s left template are not mutually contained (i.e. not equivalent up to subtyping). Fixes #10 --- .../Compose/AdjacentTemplateValidator.php | 114 ++++++++++++++++++ src/Psalm/Iso/Provider/ComposeProvider.php | 17 ++- src/Psalm/Lens/Provider/ComposeProvider.php | 17 ++- tests/static-analyzer/Iso/compose.php | 3 +- tests/static-analyzer/Lens/compose.php | 3 +- 5 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 src/Psalm/Compose/AdjacentTemplateValidator.php diff --git a/src/Psalm/Compose/AdjacentTemplateValidator.php b/src/Psalm/Compose/AdjacentTemplateValidator.php new file mode 100644 index 0000000..20e7da6 --- /dev/null +++ b/src/Psalm/Compose/AdjacentTemplateValidator.php @@ -0,0 +1,114 @@ +, Iso, Iso). + * + * This validator walks the adjacent arg pairs and emits InvalidArgument + * when the right template of arg[i] is not equivalent to the left template + * of arg[i+1]. + */ +final class AdjacentTemplateValidator +{ + /** + * @param class-string $genericClass Iso::class or Lens::class + * @param non-empty-string $functionId + */ + public static function validate( + FunctionReturnTypeProviderEvent $event, + string $genericClass, + string $functionId, + ): void { + $args = $event->getCallArgs(); + if (count($args) < 2) { + return; + } + + $source = $event->getStatementsSource(); + $nodeTypes = $source->getNodeTypeProvider(); + $codebase = $source->getCodebase(); + $suppressed = $source->getSuppressedIssues(); + + $genericClassLc = strtolower($genericClass); + + $previousRight = null; + $previousIndex = 0; + foreach ($args as $index => $arg) { + $argType = $nodeTypes->getType($arg->value); + if ($argType === null) { + $previousRight = null; + continue; + } + + $generic = self::extractGeneric($argType, $genericClassLc); + if ($generic === null) { + $previousRight = null; + continue; + } + + [$left, $right] = $generic; + + if ($previousRight !== null) { + $forward = UnionTypeComparator::isContainedBy($codebase, $previousRight, $left); + $backward = UnionTypeComparator::isContainedBy($codebase, $left, $previousRight); + + if (!$forward || !$backward) { + IssueBuffer::maybeAdd( + new InvalidArgument( + 'Argument ' . ($index + 1) . ' of ' . $functionId + . ' expects ' . $genericClass . '<' . $previousRight->getId() . ', ...>,' + . ' ' . $genericClass . '<' . $left->getId() . ', ...> provided' + . ' (compose boundary mismatch with argument ' . ($previousIndex + 1) . ')', + new CodeLocation($source, $arg->value), + $functionId, + ), + $suppressed, + ); + } + } + + $previousRight = $right; + $previousIndex = $index; + } + } + + /** + * @param lowercase-string $genericClassLc + * @return array{0: Union, 1: Union}|null + */ + private static function extractGeneric(Union $argType, string $genericClassLc): ?array + { + foreach ($argType->getAtomicTypes() as $atomic) { + if (!$atomic instanceof TGenericObject) { + continue; + } + if (strtolower($atomic->value) !== $genericClassLc) { + continue; + } + if (count($atomic->type_params) < 2) { + continue; + } + + return [$atomic->type_params[0], $atomic->type_params[1]]; + } + + return null; + } +} diff --git a/src/Psalm/Iso/Provider/ComposeProvider.php b/src/Psalm/Iso/Provider/ComposeProvider.php index 3a4013a..83ecd5f 100644 --- a/src/Psalm/Iso/Provider/ComposeProvider.php +++ b/src/Psalm/Iso/Provider/ComposeProvider.php @@ -7,24 +7,37 @@ use Psalm\Plugin\DynamicTemplateProvider; use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface; use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent; +use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; +use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; use Psalm\Storage\FunctionLikeParameter; use Psalm\Type\Atomic\TGenericObject; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; use VeeWee\Reflecta\Iso\Iso; +use VeeWee\Reflecta\Psalm\Compose\AdjacentTemplateValidator; use function array_map; use function count; use function range; -final class ComposeProvider implements DynamicFunctionStorageProviderInterface +final class ComposeProvider implements DynamicFunctionStorageProviderInterface, FunctionReturnTypeProviderInterface { + private const FUNCTION_ID = 'veewee\reflecta\iso\compose'; + /** * @return array */ public static function getFunctionIds(): array { - return ['veewee\reflecta\iso\compose']; + return [self::FUNCTION_ID]; + } + + public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union + { + AdjacentTemplateValidator::validate($event, Iso::class, self::FUNCTION_ID); + + // Defer return type to the DynamicFunctionStorage provider. + return null; } public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage diff --git a/src/Psalm/Lens/Provider/ComposeProvider.php b/src/Psalm/Lens/Provider/ComposeProvider.php index f5d6de9..6a080e1 100644 --- a/src/Psalm/Lens/Provider/ComposeProvider.php +++ b/src/Psalm/Lens/Provider/ComposeProvider.php @@ -7,24 +7,37 @@ use Psalm\Plugin\DynamicTemplateProvider; use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface; use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent; +use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; +use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; use Psalm\Storage\FunctionLikeParameter; use Psalm\Type\Atomic\TGenericObject; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; use VeeWee\Reflecta\Lens\Lens; +use VeeWee\Reflecta\Psalm\Compose\AdjacentTemplateValidator; use function array_map; use function count; use function range; -final class ComposeProvider implements DynamicFunctionStorageProviderInterface +final class ComposeProvider implements DynamicFunctionStorageProviderInterface, FunctionReturnTypeProviderInterface { + private const FUNCTION_ID = 'veewee\reflecta\lens\compose'; + /** * @return array */ public static function getFunctionIds(): array { - return ['veewee\reflecta\lens\compose']; + return [self::FUNCTION_ID]; + } + + public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union + { + AdjacentTemplateValidator::validate($event, Lens::class, self::FUNCTION_ID); + + // Defer return type to the DynamicFunctionStorage provider. + return null; } public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage diff --git a/tests/static-analyzer/Iso/compose.php b/tests/static-analyzer/Iso/compose.php index b78964f..b424421 100644 --- a/tests/static-analyzer/Iso/compose.php +++ b/tests/static-analyzer/Iso/compose.php @@ -32,8 +32,7 @@ function it_knows_composed_result(Iso $iso1, Iso $iso2, Iso $iso3): Iso * @param Iso $iso3 * @return Iso * - * @ psalm-suppress InvalidArgument - - * TODO : Invalid compose iso-param detection does not work any since contravariant templates are introduced. + * @psalm-suppress InvalidArgument */ function it_knows_broken_composition(Iso $iso1, Iso $iso2, Iso $iso3): Iso { diff --git a/tests/static-analyzer/Lens/compose.php b/tests/static-analyzer/Lens/compose.php index 0fa6613..d0e6062 100644 --- a/tests/static-analyzer/Lens/compose.php +++ b/tests/static-analyzer/Lens/compose.php @@ -32,8 +32,7 @@ function it_knows_composed_result(Lens $lens1, Lens $lens2, Lens $lens3): Lens * @param Lens $lens3 * @return Lens * - * @ psalm-suppress InvalidArgument - - * TODO : Invalid compose lens-param detection does not work any since contravariant templates are introduced. + * @psalm-suppress InvalidArgument */ function it_knows_broken_composition(Lens $lens1, Lens $lens2, Lens $lens3): Lens { From df163d2dc76298844e1e77d77811b2ff33a52ec3 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Tue, 26 May 2026 09:58:24 +0200 Subject: [PATCH 2/2] Apply cs-fixer to AdjacentTemplateValidator --- src/Psalm/Compose/AdjacentTemplateValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Psalm/Compose/AdjacentTemplateValidator.php b/src/Psalm/Compose/AdjacentTemplateValidator.php index 20e7da6..c2a57fd 100644 --- a/src/Psalm/Compose/AdjacentTemplateValidator.php +++ b/src/Psalm/Compose/AdjacentTemplateValidator.php @@ -5,8 +5,8 @@ use Psalm\CodeLocation; use Psalm\Internal\Type\Comparator\UnionTypeComparator; -use Psalm\IssueBuffer; use Psalm\Issue\InvalidArgument; +use Psalm\IssueBuffer; use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; use Psalm\Type\Atomic\TGenericObject; use Psalm\Type\Union;