fix(translator): covariant typed class constants and interface constant contracts (#58) --skip-tests

* fix(translator): make typed class-constant overrides covariant

checkConstantOverride() required exact type equality between a child
constant and the parent's declared type, rejecting valid PHP 8.3
programs: class constant types are covariant, so a child may narrow
(parent `const int|string X` overridden by `const int X`, or
`?int` by `int`) but never widen or move to an unrelated type
(Zend: "Type of B::X must be compatible with A::X of type int").

Composite declared types (unions, nullables) were also collapsed to a
single variant type at parse time, making them unrepresentable in the
check. ConstantDef now records the accepted-types DNF of its declared
type (built by the existing buildTypeCheckFromNode machinery in a
parseClassConstDef override, while the declaration's name-resolution
context is still active), and the override check reuses the DNF
clause-subtyping used for covariant returns. Untyped parent constants
remain unchecked, and a typed parent still requires a typed child.

* fix(translator): validate interface constant contracts

Interface constants were never validated: checkInterfaceImplementation()
had no constants loop and checkConstantOverride() only walks the class
extends chain. Incompatible retypings, final-constant overrides,
narrowed visibility and ambiguous multi-interface inheritance were all
accepted (all fatal in Zend 8.4).

Model Zend's constants-table merge (zend_do_inheritance +
do_inherit_constant_check): a class-like's effective table is built from
the parent class's table (private constants are not inherited), its own
declarations, then its interfaces, each entry keeping the ORIGINAL
declaring class/interface. When a same-name constant arrives from a
different declaration:

  - a FINAL inherited constant cannot be overridden — "C::X cannot
    override final constant I::X" — including through an ancestor class
    that implemented the interface (the origin travels with the entry);
  - two different declarations are ambiguous unless the type declares
    the constant itself — "Class C inherits both I1::X and I2::X,
    which is ambiguous" (a diamond of one declaration is fine);
  - an override of an interface constant must stay public — "Access
    level to C::X must be public (as in interface I)";
  - a typed interface constant requires a typed, covariant override; an
    untyped one may be redefined freely.

The same validation runs for interfaces extending interfaces and for
enums implementing interfaces. Enum cases live in a separate table in
Zend and never conflict with inherited constants.
master
Alessio Giacobbe 4 days ago committed by GitHub
parent fd20616236
commit fde9e3a9d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 17
      phpunit/code/const_override_covariant.php
  2. 12
      phpunit/code/const_override_unrelated.php
  3. 12
      phpunit/code/const_override_widened.php
  4. 13
      phpunit/code/enum_interface_const_final.php
  5. 14
      phpunit/code/interface_const_ambiguous.php
  6. 12
      phpunit/code/interface_const_final_override.php
  7. 14
      phpunit/code/interface_const_final_via_parent.php
  8. 12
      phpunit/code/interface_const_type_mismatch.php
  9. 58
      phpunit/code/interface_const_valid.php
  10. 12
      phpunit/code/interface_const_visibility.php
  11. 12
      phpunit/code/interface_extends_const_incompatible.php
  12. 32
      phpunit/src/ConstantOverrideCovarianceTest.php
  13. 74
      phpunit/src/InterfaceConstantTest.php
  14. 10
      src/Entity/ConstantDef.php
  15. 343
      src/Translator.php

@ -0,0 +1,17 @@
<?php
class A
{
const int|string X = 1;
const mixed M = 1;
const ?int N = null;
}
class B extends A
{
// PHP 8.3 typed constants are covariant: narrowing is allowed.
const int X = 2;
const string M = 'a';
const int N = 5;
}
function main() {}

@ -0,0 +1,12 @@
<?php
class A
{
const int X = 1;
}
class B extends A
{
const string X = 'a';
}
function main() {}

@ -0,0 +1,12 @@
<?php
class A
{
const int X = 1;
}
class B extends A
{
const int|string X = 2;
}
function main() {}

@ -0,0 +1,13 @@
<?php
interface I
{
final const X = 1;
}
enum E implements I
{
const X = 2;
case A;
}
function main() {}

@ -0,0 +1,14 @@
<?php
interface I1
{
const X = 1;
}
interface I2
{
const X = 1;
}
class C implements I1, I2 {}
function main() {}

@ -0,0 +1,12 @@
<?php
interface I
{
final const X = 1;
}
class C implements I
{
const X = 2;
}
function main() {}

@ -0,0 +1,14 @@
<?php
interface I
{
final const X = 1;
}
class P implements I {}
class C extends P
{
const X = 2;
}
function main() {}

@ -0,0 +1,12 @@
<?php
interface I
{
const int X = 1;
}
class C implements I
{
const string X = 'a';
}
function main() {}

@ -0,0 +1,58 @@
<?php
interface Base
{
const X = 1;
}
// Diamond: the same original declaration reached through two paths.
interface L extends Base {}
interface R extends Base {}
class Diamond implements L, R {}
interface Typed
{
const int|string N = 1;
const ?int M = null;
}
// Covariant narrowing of a typed interface constant.
class Narrowed implements Typed
{
const int N = 2;
const int M = 5;
}
interface Untyped
{
const V = 1;
}
// An untyped interface constant may be redefined with any value and type.
class Redefined implements Untyped
{
const V = 'other';
}
interface AConst
{
const W = 1;
}
interface BConst
{
const W = 2;
}
// The class's own declaration resolves the two-interface ambiguity.
class Resolves implements AConst, BConst
{
const W = 3;
}
// Enum cases live in a separate table and never conflict with constants.
enum CaseName implements Untyped
{
case V;
}
function main() {}

@ -0,0 +1,12 @@
<?php
interface I
{
const X = 1;
}
class C implements I
{
protected const X = 1;
}
function main() {}

@ -0,0 +1,12 @@
<?php
interface I
{
const int X = 1;
}
interface J extends I
{
const string X = 'a';
}
function main() {}

@ -0,0 +1,32 @@
<?php
use TypePhp\Exception\TestError;
/**
* PHP 8.3 typed class constants are covariant: an override may narrow the
* declared type (int|string -> int, mixed -> string, ?int -> int) but never
* widen it or move to an unrelated type.
*/
class ConstantOverrideCovarianceTest extends BaseTest
{
public function testNarrowingDeclaredTypeCompiles(): void
{
$this->compile('const_override_covariant.php');
}
public function testWideningDeclaredTypeIsRejected(): void
{
$this->exec(
'Declaration of `B::X` must be compatible with `A::X`',
'const_override_widened.php',
);
}
public function testUnrelatedDeclaredTypeIsRejected(): void
{
$this->exec(
'Declaration of `B::X` must be compatible with `A::X`',
'const_override_unrelated.php',
);
}
}

@ -0,0 +1,74 @@
<?php
use TypePhp\Exception\TestError;
/**
* Interface constants are real contracts in Zend: a final one cannot be
* overridden anywhere below (the original declaring interface is kept in the
* inherited constants table), a typed one is covariant, an override must stay
* public, and the same name arriving from two different declarations is
* ambiguous unless the class declares the constant itself.
*/
class InterfaceConstantTest extends BaseTest
{
public function testValidInterfaceConstantPatternsCompile(): void
{
$this->compile('interface_const_valid.php');
}
public function testTypedInterfaceConstantMustBeCovariant(): void
{
$this->exec(
'Declaration of `C::X` must be compatible with `I::X`',
'interface_const_type_mismatch.php',
);
}
public function testFinalInterfaceConstantCannotBeOverridden(): void
{
$this->exec(
'`C::X` cannot override final constant `I::X`',
'interface_const_final_override.php',
);
}
public function testFinalInterfaceConstantBindsTransitiveSubclasses(): void
{
$this->exec(
'`C::X` cannot override final constant `I::X`',
'interface_const_final_via_parent.php',
);
}
public function testSameConstantFromTwoInterfacesIsAmbiguous(): void
{
$this->exec(
'Class `C` inherits both `I1::X` and `I2::X`, which is ambiguous',
'interface_const_ambiguous.php',
);
}
public function testInterfaceConstantOverrideMustStayPublic(): void
{
$this->exec(
'Access level to `C::X` must be public (as in interface `I`)',
'interface_const_visibility.php',
);
}
public function testInterfaceExtendingInterfaceChecksConstantTypes(): void
{
$this->exec(
'Declaration of `J::X` must be compatible with `I::X`',
'interface_extends_const_incompatible.php',
);
}
public function testEnumCannotOverrideFinalInterfaceConstant(): void
{
$this->exec(
'`E::X` cannot override final constant `I::X`',
'enum_interface_const_final.php',
);
}
}

@ -24,6 +24,16 @@ class ConstantDef
/** Explicit declared type (e.g. `const int FOO`); null for inferred/untyped constants. */ /** Explicit declared type (e.g. `const int FOO`); null for inferred/untyped constants. */
public ?string $declaredType = null; public ?string $declaredType = null;
/**
* Accepted-types DNF for the explicitly declared type, in the same format
* as ArgInfo::$typeCheck. Empty when the constant is untyped or the
* declared type accepts everything (`mixed`).
*/
public array $typeCheck = [];
/** Human-readable declared type string for diagnostics ('' when untyped). */
public string $typeStr = '';
public function __construct(string $name, int $flags, string $type, string $value) public function __construct(string $name, int $flags, string $type, string $value)
{ {
$this->name = $name; $this->name = $name;

@ -82,6 +82,15 @@ class Translator extends Preprocessor
// Windows resource file configuration (icon, version info, etc.) // Windows resource file configuration (icon, version info, etc.)
protected array $resourceConfig = []; protected array $resourceConfig = [];
/**
* Memoized effective constant tables (constant name => constant and its
* original declaring class/interface), keyed by lowercased class-like
* name. See getEffectiveConstantTable().
*
* @var array<string, array<string, array{const: ConstantDef, origin: string}>>
*/
private array $effectiveConstantTables = [];
protected array $globalHeaders = [ protected array $globalHeaders = [
'cstring', 'cstring',
'phpx.h', 'phpx.h',
@ -2856,6 +2865,7 @@ CODE;
$this->parseConstDef($v); $this->parseConstDef($v);
} elseif ($v instanceof Node\Stmt\Interface_) { } elseif ($v instanceof Node\Stmt\Interface_) {
$this->validateInterfaceOverrideAttributes($v); $this->validateInterfaceOverrideAttributes($v);
$this->validateInterfaceConstants($v);
} elseif (!$v instanceof Node\Stmt\Nop) { } elseif (!$v instanceof Node\Stmt\Nop) {
$this->unsupportedSyntax($v); $this->unsupportedSyntax($v);
} }
@ -3030,6 +3040,7 @@ CODE;
$this->parseGroupUse($v2); $this->parseGroupUse($v2);
} elseif ($v2 instanceof Node\Stmt\Interface_) { } elseif ($v2 instanceof Node\Stmt\Interface_) {
$this->validateInterfaceOverrideAttributes($v2); $this->validateInterfaceOverrideAttributes($v2);
$this->validateInterfaceConstants($v2);
} elseif (!$v2 instanceof Node\Stmt\Nop) { } elseif (!$v2 instanceof Node\Stmt\Nop) {
$this->unsupportedSyntax($v2); $this->unsupportedSyntax($v2);
} }
@ -4109,6 +4120,7 @@ CODE;
if (!$class instanceof Node\Stmt\Trait_) { if (!$class instanceof Node\Stmt\Trait_) {
$this->validateOverrideAttributes($class); $this->validateOverrideAttributes($class);
$this->checkInterfaceImplementations($class); $this->checkInterfaceImplementations($class);
$this->checkInheritedConstantContracts($class);
$this->checkInheritedAbstractMethodsAreImplemented($class); $this->checkInheritedAbstractMethodsAreImplemented($class);
} }
$code = $this->genNativeMethod($methodCodes); $code = $this->genNativeMethod($methodCodes);
@ -5481,6 +5493,303 @@ CODE;
return $this->getVisibilityRank($property->flags); return $this->getVisibilityRank($property->flags);
} }
/**
* Parse a class constant declaration and additionally record the
* accepted-types DNF of its declared type. The base implementation
* collapses composite declared types (unions, nullables, intersections)
* to a single variant type, which is too coarse for the covariant
* constant-override checks; the DNF preserves the full declaration.
*/
protected function parseClassConstDef(Node\Stmt\ClassConst $v): void
{
parent::parseClassConstDef($v);
if ($v->type === null) {
return;
}
$typeInfo = $this->buildTypeCheckFromNode($v->type, true);
foreach ($v->consts as $const) {
$constName = $this->parseIdentifier($const->name);
if ($this->classDef !== null && $this->classDef->hasConstant($constName)) {
$constDef = $this->classDef->getConstant($constName);
$constDef->typeCheck = $typeInfo['check'];
$constDef->typeStr = $typeInfo['typeStr'];
}
}
}
/**
* Parse an interface declaration and additionally record the
* accepted-types DNF of each typed constant, mirroring the
* parseClassConstDef() override above. The base implementation collapses
* composite declared types to a single variant type.
*/
protected function parseInterface(Node\Stmt\Interface_ $v): void
{
parent::parseInterface($v);
$name = $this->parseIdentifier($v->name);
$interfaceName = $this->namespace === '' ? $name : $this->namespace . '\\' . $name;
if (!$this->hasInterface($interfaceName)) {
return;
}
$interfaceDef = $this->getInterface($interfaceName);
foreach ($v->stmts as $stmt) {
if (!$stmt instanceof Node\Stmt\ClassConst || $stmt->type === null) {
continue;
}
$typeInfo = $this->buildTypeCheckFromNode($stmt->type, true);
foreach ($stmt->consts as $const) {
$constName = $this->parseIdentifier($const->name);
if (isset($interfaceDef->constants[$constName])) {
$interfaceDef->constants[$constName]->typeCheck = $typeInfo['check'];
$interfaceDef->constants[$constName]->typeStr = $typeInfo['typeStr'];
}
}
}
}
/**
* Accepted-types DNF for a constant's DECLARED type, or null when the
* constant is untyped (no type contract to enforce on overrides).
*/
private function getConstantAcceptedTypes(ConstantDef $const): ?array
{
if ($const->declaredType === null) {
return null;
}
if ($const->typeCheck !== []) {
return $const->typeCheck;
}
return match ($const->declaredType) {
Type::INT => [['kind' => 'isInt']],
Type::FLOAT => [['kind' => 'isFloat']],
Type::BOOL => [['kind' => 'isBool']],
Type::STR => [['kind' => 'isString']],
Type::ARRAY => [['kind' => 'isArray']],
Type::RESOURCE => [['kind' => 'isResource']],
Type::OBJECT => $const->class !== ''
? [['kind' => 'instanceof', 'class' => $const->class]]
: [['kind' => 'isObject']],
default => [['kind' => 'isMixed']],
};
}
/**
* PHP 8.3 typed class constants are covariant: an override may narrow the
* declared type but never widen it or move to an unrelated type.
*/
private function isConstantTypeOverrideCompatible(ConstantDef $childConst, array $parentAccepted): bool
{
if ($childConst->declaredType === null) {
return false;
}
foreach ($this->getConstantAcceptedTypes($childConst) as $childType) {
if (!$this->isReturnTypeCoveredBy($childType, $parentAccepted)) {
return false;
}
}
return true;
}
/**
* The constants visible on a class-like, keyed by name, each entry
* carrying its ConstantDef and the ORIGINAL declaring class or interface.
* Mirrors Zend's constants-table build order (parent class first, own
* declarations, then interfaces). Conflicts between ancestors are resolved
* silently here — first entry wins — because they are reported by the
* declaring type's own validation pass when it is compiled.
*
* @return array<string, array{const: ConstantDef, origin: string}>
*/
private function getEffectiveConstantTable(ClassDef|InterfaceDef $def): array
{
$ownName = $def->getNamespacedName(false);
$key = strtolower($ownName);
if (isset($this->effectiveConstantTables[$key])) {
return $this->effectiveConstantTables[$key];
}
$table = [];
if ($def instanceof ClassDef) {
if ($def->extends !== '' && !$def->inheritedFromInternalClass && $this->hasClass($def->extends)) {
foreach ($this->getEffectiveConstantTable($this->getClass($def->extends)) as $name => $entry) {
// Private constants are not inherited.
if (!($entry['const']->flags & Modifiers::PRIVATE)) {
$table[$name] = $entry;
}
}
}
foreach ($def->constants as $name => $const) {
$table[$name] = ['const' => $const, 'origin' => $ownName];
}
$parents = $def->implements;
} else {
foreach ($def->constants as $name => $const) {
$table[$name] = ['const' => $const, 'origin' => $ownName];
}
$parents = $def->extendsList ?: ($def->extends ? [$def->extends] : []);
}
foreach ($parents as $interfaceName) {
if (!$this->hasInterface($interfaceName)) {
continue;
}
foreach ($this->getEffectiveConstantTable($this->getInterface($interfaceName)) as $name => $entry) {
$table[$name] ??= $entry;
}
}
return $this->effectiveConstantTables[$key] = $table;
}
/**
* Validate the class's constants against every constant contract arriving
* through an interface — implemented directly, inherited through a parent
* interface, or carried by an ancestor class (Zend keeps the original
* declaring interface in the inherited constants table, so a final or
* typed interface constant binds every transitive subclass):
*
* - a final interface constant cannot be overridden;
* - the same constant name arriving from two different declarations is
* ambiguous unless the class declares it itself;
* - an override of an interface constant must stay public;
* - typed constants are covariant (see checkConstantOverride()).
*/
private function checkInheritedConstantContracts(Node\Stmt\Class_|Node\Stmt\Enum_ $classStmt): void
{
$classDef = $this->classDef;
$className = $classDef->getNamespacedName(false);
// Constants visible before this class's interfaces are merged: the
// parent chain's effective table, then the class's own declarations.
$table = [];
if ($classDef->extends !== '' && !$classDef->inheritedFromInternalClass && $this->hasClass($classDef->extends)) {
foreach ($this->getEffectiveConstantTable($this->getClass($classDef->extends)) as $name => $entry) {
if (!($entry['const']->flags & Modifiers::PRIVATE)) {
$table[$name] = $entry;
}
}
}
foreach ($classDef->constants as $name => $const) {
if (isset($table[$name]) && $this->hasInterface($table[$name]['origin'])) {
// The nearest inherited declaration originates in an interface
// reached through an ancestor class; class-chain declarations
// are validated by checkConstantOverride() instead.
$this->validateConstantAgainstInheritedEntry(
$classStmt,
'Class',
$className,
$name,
['const' => $const, 'origin' => $className],
$table[$name],
);
}
$table[$name] = ['const' => $const, 'origin' => $className];
}
foreach ($classDef->implements as $interfaceName) {
if (!$this->hasInterface($interfaceName)) {
continue;
}
foreach ($this->getEffectiveConstantTable($this->getInterface($interfaceName)) as $name => $entry) {
if (!isset($table[$name])) {
$table[$name] = $entry;
continue;
}
$this->validateConstantAgainstInheritedEntry(
$classStmt,
'Class',
$className,
$name,
$table[$name],
$entry,
);
}
}
}
/**
* Validate an interface's own constants against the ones inherited from
* its parent interfaces, and inherited same-name constants against each
* other (Zend: ambiguous unless declared by the interface itself).
*/
private function validateInterfaceConstants(Node\Stmt\Interface_ $interfaceStmt): void
{
$name = $this->parseIdentifier($interfaceStmt->name);
$interfaceName = $this->namespace === '' ? $name : $this->namespace . '\\' . $name;
if (!$this->hasInterface($interfaceName)) {
return;
}
$interfaceDef = $this->getInterface($interfaceName);
$table = [];
foreach ($interfaceDef->constants as $constName => $const) {
$table[$constName] = ['const' => $const, 'origin' => $interfaceName];
}
foreach ($interfaceDef->extendsList ?: ($interfaceDef->extends ? [$interfaceDef->extends] : []) as $parentName) {
if (!$this->hasInterface($parentName)) {
continue;
}
foreach ($this->getEffectiveConstantTable($this->getInterface($parentName)) as $constName => $entry) {
if (!isset($table[$constName])) {
$table[$constName] = $entry;
continue;
}
$this->validateConstantAgainstInheritedEntry(
$interfaceStmt,
'Interface',
$interfaceName,
$constName,
$table[$constName],
$entry,
);
}
}
}
/**
* Zend's do_inherit_constant_check: $existing is the constant already in
* the type's table (its own declaration, or one inherited earlier), and
* $incoming the same-name constant arriving from another declaration.
*
* @param array{const: ConstantDef, origin: string} $existing
* @param array{const: ConstantDef, origin: string} $incoming
*/
private function validateConstantAgainstInheritedEntry(
NodeAbstract $node,
string $kind,
string $typeName,
string $constName,
array $existing,
array $incoming,
): void {
if ($existing['origin'] === $incoming['origin']) {
// The same original declaration reached through two paths
// (diamond inheritance) never conflicts.
return;
}
if ($incoming['const']->flags & Modifiers::FINAL) {
$this->fatalError($node,
"`{$existing['origin']}::{$constName}` cannot override final constant " .
"`{$incoming['origin']}::{$constName}`");
}
if ($existing['origin'] !== $typeName) {
$this->fatalError($node,
"{$kind} `{$typeName}` inherits both `{$existing['origin']}::{$constName}` and " .
"`{$incoming['origin']}::{$constName}`, which is ambiguous");
}
// The type's own declaration overrides the inherited constant.
$childConst = $existing['const'];
if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($incoming['const']->flags)) {
$this->fatalError($node,
"Access level to `{$typeName}::{$constName}` must be public " .
"(as in interface `{$incoming['origin']}`)");
}
$parentAccepted = $this->getConstantAcceptedTypes($incoming['const']);
if ($parentAccepted !== null && !$this->isConstantTypeOverrideCompatible($childConst, $parentAccepted)) {
$this->fatalError($node,
"Declaration of `{$typeName}::{$constName}` must be compatible " .
"with `{$incoming['origin']}::{$constName}`");
}
}
private function checkConstantOverride(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $classStmt): void private function checkConstantOverride(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $classStmt): void
{ {
$classDef = $this->classDef; $classDef = $this->classDef;
@ -5505,30 +5814,16 @@ CODE;
// PHP only enforces type compatibility when the parent constant // PHP only enforces type compatibility when the parent constant
// carries an explicit declared type. Overriding an untyped constant // carries an explicit declared type. Overriding an untyped constant
// with a value of any type is permitted, so the type check is skipped // with a value of any type is permitted, so the type check is skipped
// in that case. Visibility is always enforced below. // in that case. Typed constants are covariant (PHP 8.3): the child
if ($parentConst->declaredType !== null) { // may narrow the declared type (e.g. int|string -> int) but never
if ($childConst->declaredType === null) { // widen it or move to an unrelated type. Visibility is always
$this->fatalError($classStmt, // enforced below.
"Declaration of `{$className}::{$name}` must be compatible " . $parentAccepted = $this->getConstantAcceptedTypes($parentConst);
"with `{$parentClass}::{$name}`"); if ($parentAccepted !== null
} && !$this->isConstantTypeOverrideCompatible($childConst, $parentAccepted)) {
// An untyped child constant whose value is an expression (e.g. $this->fatalError($classStmt,
// `X = ParentClass::Y`) is inferred as a variant. Resolve its real "Declaration of `{$className}::{$name}` must be compatible " .
// type from the referenced constant so the compatibility check uses "with `{$parentClass}::{$name}`");
// the actual value type.
$childType = $childConst->type;
if ($childType === Type::VAR
&& $childConst->valueExpr instanceof Node\Expr\ClassConstFetch) {
$resolved = $this->resolveReferencedConstantType($childConst->valueExpr, $this->getFullClassName());
if ($resolved !== null) {
$childType = $resolved;
}
}
if ($childType !== $parentConst->type || $childConst->class !== $parentConst->class) {
$this->fatalError($classStmt,
"Declaration of `{$className}::{$name}` must be compatible " .
"with `{$parentClass}::{$name}`");
}
} }
if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($parentConst->flags)) { if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($parentConst->flags)) {
$this->fatalError($classStmt, $this->fatalError($classStmt,

Loading…
Cancel
Save