fix(preprocessor): reject variadic promoted properties and callable property/constant types (#64) --skip-tests

* fix(preprocessor): reject variadic promoted properties and callable property/constant types

Two promotion/type gaps against Zend (probed on 8.4.13):

- `__construct(public int ...$x)` was accepted and even registered
  the property before the variadic-position check ran. A variadic
  parameter collects its arguments into an array, so there is no single
  value to promote; Zend fatals with "Cannot declare variadic promoted
  property". The check now precedes the property registration.
- `callable` is a calling-scope-dependent type, so Zend forbids it in
  property types (declared, promoted, interface hooked) and class
  constant types (class and interface), bare or as a nullable/union
  member: "Property A::$x cannot have type ?callable" /
  "Class constant A::X cannot have type callable". Intersection
  members are left to the compound-type validation, which rejects
  every non-class standard type there.

`void`/`never` property and parameter types were already rejected by
parseTypeDecl ("The type `void`/`never` is allowed only for return
type") - verified, no change needed; union members are covered by the
compound-type validation.

* test(preprocessor): cover promotion and property/constant type rules

* fix(preprocessor): reject callable inside intersection and DNF types

typeDeclContainsCallable() deliberately skipped IntersectionType, so a
DNF-nested callable such as `public (Traversable&callable)|stdClass
$value;` sailed past the property checks and died in gen_stub on
assert(!$type->isBuiltin); a bare `Traversable&callable` property
compiled outright.

Zend rejects callable while compiling the intersection type itself,
with its own diagnostic ("Type callable cannot be part of an
intersection type", probed on 8.4.13), in every declaration context and
ahead of the property/constant-specific bans — `callable|(Traversable&
callable)` reports the intersection conflict, not the property one.

A dedicated assertTypeDeclIntersectionsHaveNoCallable() walk (nullable,
union, intersection members) now runs before the existing
typeDeclContainsCallable() checks in all contexts this branch guards:
class properties, promoted properties (both via addClassProperty),
typed class constants, and interface properties/constants. Tests cover
the bare intersection member, DNF in first and second union member,
the promoted and constant/interface variants, and a callable-free DNF
property that must keep compiling.

* fix(preprocessor): validate callable intersections on the common type-declaration path

assertTypeDeclIntersectionsHaveNoCallable() was invoked only from the
property and class/interface-constant paths, so the same invalid type
in a function parameter or return declaration bypassed the check and
reached the later generator path:

    function consume(Traversable&callable $value): void {}
    function produce(): Traversable&callable {}

Zend rejects both while compiling the type itself ("Type callable
cannot be part of an intersection type", probed on 8.4.13), in every
declaration context.

The walk now lives in parseTypeDecl(), the declaration funnel behind
resolveTypeDecl() that parameters, returns, properties, promoted
properties, class and interface constants, and interface hooked
properties already flow through; the per-context calls are gone, and
the diagnostic points at the offending intersection member. Closure
and arrow-function signatures resolved no full type node anywhere, so
doGenClosure() now routes them through the same funnel - except bare
class names, which the native-object walk there already resolves (and,
inside trait methods, rewrites) via parseTypeDecl().

The property and constant paths resolve the declaration before
applying their own bare/nullable/union callable bans, so a type like
`callable|(Traversable&callable)` keeps reporting the intersection
conflict first, as Zend does.

New negative tests: parameter and return intersections, callable in a
DNF parameter member, and closure parameter and return intersections
(each probed against Zend 8.4.13); positive tests keep bare `callable`
parameters and callable-free DNF properties compiling.
master
Alessio Giacobbe 2 days ago committed by GitHub
parent 4ca9072f36
commit f39f985e16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      phpunit/code/closure_rule_callable_intersection_param.php
  2. 5
      phpunit/code/closure_rule_callable_intersection_return.php
  3. 4
      phpunit/code/const_rule_callable.php
  4. 4
      phpunit/code/const_rule_callable_dnf.php
  5. 4
      phpunit/code/interface_rule_callable_dnf.php
  6. 4
      phpunit/code/param_rule_callable_dnf.php
  7. 4
      phpunit/code/param_rule_callable_intersection.php
  8. 8
      phpunit/code/param_rule_callable_valid.php
  9. 4
      phpunit/code/promotion_rule_variadic.php
  10. 4
      phpunit/code/property_rule_callable.php
  11. 4
      phpunit/code/property_rule_callable_dnf.php
  12. 4
      phpunit/code/property_rule_callable_dnf_promoted.php
  13. 4
      phpunit/code/property_rule_callable_dnf_second_member.php
  14. 4
      phpunit/code/property_rule_callable_intersection.php
  15. 4
      phpunit/code/property_rule_callable_promoted.php
  16. 4
      phpunit/code/property_rule_callable_union.php
  17. 4
      phpunit/code/property_rule_dnf_valid.php
  18. 4
      phpunit/code/return_rule_callable_intersection.php
  19. 105
      phpunit/src/PromotionAndPropertyTypeTest.php
  20. 14
      src/Generator/ClosureGenerator.php
  21. 67
      src/Preprocessor.php
  22. 34
      src/Resolver/NameResolutionTrait.php

@ -0,0 +1,5 @@
<?php
function main() {
$f = function (Traversable&callable $x): void {};
$f(null);
}

@ -0,0 +1,5 @@
<?php
function main() {
$f = function (): Traversable&callable {};
$f();
}

@ -0,0 +1,4 @@
<?php
class Bag { const callable FN = 1; }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { const (Traversable&callable)|int FN = 1; }
function main() {}

@ -0,0 +1,4 @@
<?php
interface Baggy { public (Traversable&callable)|stdClass $fn { get; } const (Traversable&callable)|int FN = 1; }
function main() {}

@ -0,0 +1,4 @@
<?php
function consume((Traversable&callable)|stdClass $value): void {}
function main() {}

@ -0,0 +1,4 @@
<?php
function consume(Traversable&callable $value): void {}
function main() {}

@ -0,0 +1,8 @@
<?php
function apply(callable $fn): void {
$fn();
}
function main() {
apply(function (): void {});
}

@ -0,0 +1,4 @@
<?php
class Bag { public function __construct(public int ...$items) {} }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public callable $fn; }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public (Traversable&callable)|stdClass $fn; }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public function __construct(public (Traversable&callable)|stdClass $fn) {} }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public (Countable&Traversable)|(Iterator&callable) $fn; }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public Traversable&callable $fn; }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public function __construct(public callable $fn) {} }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public int|callable $fn; }
function main() {}

@ -0,0 +1,4 @@
<?php
class Bag { public (Countable&Traversable)|stdClass $it; }
function main() {}

@ -0,0 +1,4 @@
<?php
function produce(): Traversable&callable {}
function main() {}

@ -0,0 +1,105 @@
<?php
/**
* Constructor promotion and property/constant type restrictions:
* no variadic promoted properties, `callable` is banned from
* property and class-constant types (bare, nullable, or union member),
* and `callable` inside an intersection or DNF member is rejected in
* every declaration context - parameters, returns, properties, promoted
* properties, constants, interface members, and closures.
*/
class PromotionAndPropertyTypeTest extends BaseTest
{
public function testVariadicPromotedPropertyIsRejected(): void
{
$this->exec('Cannot declare variadic promoted property', 'promotion_rule_variadic.php');
}
public function testCallablePropertyTypeIsRejected(): void
{
$this->exec('Property `Bag::$fn` cannot have type `callable`', 'property_rule_callable.php');
}
public function testCallablePromotedPropertyTypeIsRejected(): void
{
$this->exec('Property `Bag::$fn` cannot have type `callable`', 'property_rule_callable_promoted.php');
}
public function testCallableUnionPropertyTypeIsRejected(): void
{
$this->exec('Property `Bag::$fn` cannot have type `int|callable`', 'property_rule_callable_union.php');
}
public function testCallableClassConstantTypeIsRejected(): void
{
$this->exec('Class constant `Bag::FN` cannot have type `callable`', 'const_rule_callable.php');
}
public function testCallableInBareIntersectionIsRejected(): void
{
// Zend rejects callable while compiling the intersection type itself,
// with a dedicated diagnostic; without this check the type reaches
// gen_stub, which asserts intersection members are never builtin.
$this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_intersection.php');
}
public function testCallableInDnfPropertyTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_dnf.php');
}
public function testCallableInSecondDnfMemberIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_dnf_second_member.php');
}
public function testCallableInDnfPromotedPropertyTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_dnf_promoted.php');
}
public function testCallableInDnfClassConstantTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'const_rule_callable_dnf.php');
}
public function testCallableInDnfInterfaceMemberTypesIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'interface_rule_callable_dnf.php');
}
public function testCallableInIntersectionParameterTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'param_rule_callable_intersection.php');
}
public function testCallableInDnfParameterTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'param_rule_callable_dnf.php');
}
public function testCallableInIntersectionReturnTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'return_rule_callable_intersection.php');
}
public function testCallableInIntersectionClosureParameterTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'closure_rule_callable_intersection_param.php');
}
public function testCallableInIntersectionClosureReturnTypeIsRejected(): void
{
$this->exec('Type callable cannot be part of an intersection type', 'closure_rule_callable_intersection_return.php');
}
public function testCallableFreeDnfPropertyTypeStillCompiles(): void
{
$this->compile('property_rule_dnf_valid.php');
}
public function testBareCallableParameterTypeStillCompiles(): void
{
$this->compile('param_rule_callable_valid.php');
}
}

@ -116,6 +116,20 @@ trait ClosureGenerator
private function doGenClosure(Expr\ArrowFunction|Expr\Closure $expr, array $params, array $uses = []): string private function doGenClosure(Expr\ArrowFunction|Expr\Closure $expr, array $params, array $uses = []): string
{ {
// Closure signatures flow through the same declaration validation in
// parseTypeDecl() as named functions (e.g. callable inside an
// intersection or DNF member). Bare class names are skipped here: the
// native-object walk below already resolves each of them through
// parseTypeDecl() and owns the trait-context name rewrite, so
// resolving them twice would re-qualify an already qualified name.
foreach ($params as $param) {
if (!$param->type instanceof Node\Name) {
$this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM);
}
}
if (!$expr->returnType instanceof Node\Name) {
$this->resolveTypeDecl($expr->returnType, self::DECL_TYPE_OF_RETURN);
}
if ($this->classDef?->nativeObject && !$expr->static) { if ($this->classDef?->nativeObject && !$expr->static) {
$this->fatalError($expr, 'Native objects cannot be bound as $this to Zend closures'); $this->fatalError($expr, 'Native objects cannot be bound as $this to Zend closures');
} }

@ -855,6 +855,11 @@ class Preprocessor extends CompilerBase
if (!$this->classDef or !$this->methodDef or $this->methodDef->name !== '__construct') { if (!$this->classDef or !$this->methodDef or $this->methodDef->name !== '__construct') {
$this->fatalError($param, 'Promoted properties are not supported'); $this->fatalError($param, 'Promoted properties are not supported');
} }
// A variadic parameter collects arguments into an array, so no
// single value exists to promote into the property.
if ($param->variadic) {
$this->fatalError($param, 'Cannot declare variadic promoted property');
}
$nullable = $param->type instanceof NullableType; $nullable = $param->type instanceof NullableType;
// Promoted property defaults belong to the constructor parameter, // Promoted property defaults belong to the constructor parameter,
// not to the property default table. The property itself must stay // not to the property default table. The property itself must stay
@ -1574,6 +1579,13 @@ class Preprocessor extends CompilerBase
[$declaredType, $class] = $v->type [$declaredType, $class] = $v->type
? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST)
: [null, '']; : [null, ''];
if ($v->type !== null && $this->typeDeclContainsCallable($v->type)) {
$constName = $v->consts !== [] ? $this->parseIdentifier($v->consts[0]->name) : '';
$this->fatalError(
$v,
"Class constant `{$this->classDef->getNamespacedName(false)}::{$constName}` cannot have type `{$this->typeCheckNodeToString($v->type)}`",
);
}
foreach ($v->consts as $const) { foreach ($v->consts as $const) {
$type = $declaredType; $type = $declaredType;
@ -1714,7 +1726,19 @@ class Preprocessor extends CompilerBase
} }
} }
$this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode); $this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode);
// Resolving the declaration also runs the common compound-type
// validation (callable as an intersection/DNF member is rejected
// there, ahead of the property-specific rule, matching Zend).
[$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY);
// `callable` is a runtime-context type (a string or array may or may
// not be callable depending on scope), so Zend forbids it in property
// types entirely - bare, nullable, or as a union member.
if ($typeNode !== null && $this->typeDeclContainsCallable($typeNode)) {
$this->fatalError(
$errorNode,
"Property `{$this->classDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($typeNode)}`",
);
}
$this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode);
$nullableNative = $this->resolveNullableNativeObjectType( $nullableNative = $this->resolveNullableNativeObjectType(
$typeNode, $typeNode,
@ -1785,6 +1809,31 @@ class Preprocessor extends CompilerBase
return $propDef; return $propDef;
} }
/**
* Whether a declared type mentions `callable` outside an intersection.
* Zend forbids callable in property and class-constant types; callable
* inside an intersection is rejected first, with its own diagnostic, by
* the common declaration validation in parseTypeDecl().
*/
private function typeDeclContainsCallable(NodeAbstract $typeNode): bool
{
if ($typeNode instanceof NullableType) {
return $this->typeDeclContainsCallable($typeNode->type);
}
if ($typeNode instanceof UnionType) {
foreach ($typeNode->types as $member) {
if ($this->typeDeclContainsCallable($member)) {
return true;
}
}
return false;
}
if ($typeNode instanceof IntersectionType) {
return false;
}
return strtolower($this->parseIdentifier($typeNode)) === 'callable';
}
private function validateAsymmetricPropertyDeclaration( private function validateAsymmetricPropertyDeclaration(
string $name, string $name,
int $flags, int $flags,
@ -2453,11 +2502,14 @@ class Preprocessor extends CompilerBase
"Access type for interface constant `{$interfaceName}::{$constName}` must be public", "Access type for interface constant `{$interfaceName}::{$constName}` must be public",
); );
} }
if ($this->interfaceDef->hasConstant($constName)) {
$this->fatalError($stmt, "Duplicate constant `{$constName}`");
}
if ($stmt->type) { if ($stmt->type) {
[$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST); [$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST);
if ($this->typeDeclContainsCallable($stmt->type)) {
$this->fatalError(
$stmt,
"Class constant `{$interfaceName}::{$constName}` cannot have type `{$this->typeCheckNodeToString($stmt->type)}`",
);
}
} else { } else {
$class = ''; $class = '';
$type = match ($const->value->getType()) { $type = match ($const->value->getType()) {
@ -2466,6 +2518,9 @@ class Preprocessor extends CompilerBase
default => Type::VAR, default => Type::VAR,
}; };
} }
if ($this->interfaceDef->hasConstant($constName)) {
$this->fatalError($stmt, "Duplicate constant `{$constName}`");
}
$constInfo = $this->parseClassLikeConstant($const, $this->parseModifiers($stmt->flags), $type, $class, $stmt->type ? $type : null); $constInfo = $this->parseClassLikeConstant($const, $this->parseModifiers($stmt->flags), $type, $class, $stmt->type ? $type : null);
$this->interfaceDef->constants[$constName] = $constInfo; $this->interfaceDef->constants[$constName] = $constInfo;
} }
@ -2588,6 +2643,12 @@ class Preprocessor extends CompilerBase
$nullable = $property->type instanceof NullableType; $nullable = $property->type instanceof NullableType;
foreach ($property->props as $prop) { foreach ($property->props as $prop) {
$name = $this->parseIdentifier($prop->name); $name = $this->parseIdentifier($prop->name);
if ($property->type !== null && $this->typeDeclContainsCallable($property->type)) {
$this->fatalError(
$property,
"Property `{$this->interfaceDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($property->type)}`",
);
}
if ($property->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false)) { if ($property->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false)) {
$this->fatalCompileTimeAttribute( $this->fatalCompileTimeAttribute(
$property, $property,

@ -168,6 +168,7 @@ trait NameResolutionTrait
if ($type === null) { if ($type === null) {
return Type::VAR; return Type::VAR;
} }
$this->assertTypeDeclIntersectionsHaveNoCallable($type);
if ($type instanceof UnionType || $type instanceof NullableType || $type instanceof IntersectionType) { if ($type instanceof UnionType || $type instanceof NullableType || $type instanceof IntersectionType) {
// Complex types are uniformly treated as mixed/var at the static stage; the runtime typeCheck provides the fallback. // Complex types are uniformly treated as mixed/var at the static stage; the runtime typeCheck provides the fallback.
return Type::VAR; return Type::VAR;
@ -201,4 +202,37 @@ trait NameResolutionTrait
} }
} }
} }
/**
* Zend rejects `callable` as an intersection member while compiling the
* type itself ("Type callable cannot be part of an intersection type"),
* in every declaration context - parameters, returns, properties,
* promoted properties, class and interface constants, closures - and
* before any property/constant-specific rule fires (probed on 8.4.13:
* `callable|(Traversable&callable)` reports the intersection conflict,
* not the property one). Running the walk here, on the common
* declaration path, covers bare intersections and DNF members like
* `(Traversable&callable)|stdClass`; without it the type reaches
* gen_stub, which asserts that intersection members are never builtin.
*/
private function assertTypeDeclIntersectionsHaveNoCallable(NodeAbstract $typeNode): void
{
if ($typeNode instanceof NullableType) {
$this->assertTypeDeclIntersectionsHaveNoCallable($typeNode->type);
return;
}
if ($typeNode instanceof UnionType) {
foreach ($typeNode->types as $member) {
$this->assertTypeDeclIntersectionsHaveNoCallable($member);
}
return;
}
if ($typeNode instanceof IntersectionType) {
foreach ($typeNode->types as $member) {
if (strtolower($this->parseIdentifier($member)) === 'callable') {
$this->fatalError($member, 'Type callable cannot be part of an intersection type');
}
}
}
}
} }

Loading…
Cancel
Save