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 10 hours 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. */
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)
{
$this->name = $name;

@ -82,6 +82,15 @@ class Translator extends Preprocessor
// Windows resource file configuration (icon, version info, etc.)
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 = [
'cstring',
'phpx.h',
@ -2856,6 +2865,7 @@ CODE;
$this->parseConstDef($v);
} elseif ($v instanceof Node\Stmt\Interface_) {
$this->validateInterfaceOverrideAttributes($v);
$this->validateInterfaceConstants($v);
} elseif (!$v instanceof Node\Stmt\Nop) {
$this->unsupportedSyntax($v);
}
@ -3030,6 +3040,7 @@ CODE;
$this->parseGroupUse($v2);
} elseif ($v2 instanceof Node\Stmt\Interface_) {
$this->validateInterfaceOverrideAttributes($v2);
$this->validateInterfaceConstants($v2);
} elseif (!$v2 instanceof Node\Stmt\Nop) {
$this->unsupportedSyntax($v2);
}
@ -4109,6 +4120,7 @@ CODE;
if (!$class instanceof Node\Stmt\Trait_) {
$this->validateOverrideAttributes($class);
$this->checkInterfaceImplementations($class);
$this->checkInheritedConstantContracts($class);
$this->checkInheritedAbstractMethodsAreImplemented($class);
}
$code = $this->genNativeMethod($methodCodes);
@ -5481,6 +5493,303 @@ CODE;
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
{
$classDef = $this->classDef;
@ -5505,30 +5814,16 @@ CODE;
// PHP only enforces type compatibility when the parent constant
// carries an explicit declared type. Overriding an untyped constant
// with a value of any type is permitted, so the type check is skipped
// in that case. Visibility is always enforced below.
if ($parentConst->declaredType !== null) {
if ($childConst->declaredType === null) {
$this->fatalError($classStmt,
"Declaration of `{$className}::{$name}` must be compatible " .
"with `{$parentClass}::{$name}`");
}
// An untyped child constant whose value is an expression (e.g.
// `X = ParentClass::Y`) is inferred as a variant. Resolve its real
// type from the referenced constant so the compatibility check uses
// 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}`");
}
// in that case. Typed constants are covariant (PHP 8.3): the child
// may narrow the declared type (e.g. int|string -> int) but never
// widen it or move to an unrelated type. Visibility is always
// enforced below.
$parentAccepted = $this->getConstantAcceptedTypes($parentConst);
if ($parentAccepted !== null
&& !$this->isConstantTypeOverrideCompatible($childConst, $parentAccepted)) {
$this->fatalError($classStmt,
"Declaration of `{$className}::{$name}` must be compatible " .
"with `{$parentClass}::{$name}`");
}
if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($parentConst->flags)) {
$this->fatalError($classStmt,

Loading…
Cancel
Save