test(compiler): add comprehensive negative compatibility tests and improve error handling

- Add new NegativeCompatibilityTest class with 320 lines of test cases
- Implement closure and arrow function reference return validation with error messages
- Add foreach list destructuring reference binding validation and error reporting
- Move foreach by-reference variable validation to proper location in parser
- Update documentation to reflect foreach list destructuring reference limitations
- Refactor incompatibility classification documentation for clarity
- Remove outdated attribute argument limitation from documentation
- Create diagnostic reporter for controlled compiler boundary testing
- Add comprehensive test coverage for PHP incompatibility boundaries
- Ensure clean failure modes instead of crashes or invalid C++ emission
master
韩天峰 1 day ago
parent 23a2bf44d7
commit 6dcafb652b
  1. 3
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 3
      docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md
  3. 320
      phpunit/src/NegativeCompatibilityTest.php
  4. 2
      src/Generator/ClosureGenerator.php
  5. 11
      src/Parser/ForeachTrait.php

@ -25,7 +25,6 @@
- 不支持引用可变参数 `&...$args`
- 联合类型、交叉类型、`nullable` 类型仍以 `mixed/any` 作为 C++ 表示,但静态阶段会利用已知表达式类型提前拒绝确定不兼容的参数、返回值和属性赋值;动态值仍保留运行时 type check。
- 局部变量类型一旦被静态推断为具体 native 类型,不支持在同一作用域内重新赋值为不兼容类型。
- attribute 参数不支持非空数组值和 `new` 表达式。
## declare
@ -56,7 +55,7 @@
- `match` 的 arm condition 不能是 `match` 表达式。
- `foreach` by reference 的 value 只能是变量。
- `foreach` by reference 不支持 list destructuring。
- `foreach` list destructuring 不支持按引用绑定元素
- `std::vector`、`std::map`、`std::ordered_map` 在 `foreach` 期间禁止追加、插入、`unset()` 或整体替换;已有元素的非结构性更新仍可使用赋值运算符完成。
- 固定 native typed object property 不允许按 PHP 未初始化语义自由 `unset()`
- native 类型变量执行 `unset()` 不会产生标准 PHP 的变量删除语义。

@ -96,12 +96,11 @@ These items should be documented with the exact boundary.
| `echo` with assignment expressions | Pending | Requires expression lowering that preserves evaluation order and returns the assigned value. |
| Nested `match` expressions in arm conditions | Pending | Requires recursive match lowering and temporary value ordering. |
| `foreach` by-reference value targets beyond simple variables | Pending | Requires explicit lvalue/reference target modeling. |
| `foreach` by-reference with list destructuring | Pending | Requires by-reference foreach value lowering followed by destructuring assignment. |
| `foreach` list destructuring with by-reference items | Pending | Requires destructuring assignment to preserve references for selected list elements. |
| Dynamic `ClassName::class` | Pending | Runtime class-name resolution can be used when the class expression is dynamic. |
| `static::class` in runtime contexts | Pending / Partial | Runtime contexts can use called-class lookup. True compile-time constant contexts should remain unsupported. |
| Dynamic property chains, class names, function names and callbacks in native-optimized paths | Partial | Supported through a Zend runtime fallback. Native dispatch is only an optimization; automatic by-reference argument conversion remains unsupported. |
| First-class callable stored in nullable `Closure` typed property | Pending / Partial | Requires stable runtime lifetime, refcount and typed-property write handling. |
| Attribute arguments containing arrays or `new` expressions | Pending | Requires full constant-expression and attribute metadata generation support. |
| Static analysis of union, intersection and nullable types | Pending optimization | Requires a real union/intersection type lattice instead of treating these as `mixed/any` during static analysis. |
## Partial Support and Behavioral Differences

@ -0,0 +1,320 @@
<?php
/**
* This file is part of Swoole-Compiler(AOT).
*
* @link https://www.swoole.com/
* @contact service@swoole.com
*/
use PhpParser\Node;
use TypePhp\CompilerTest;
use TypePhp\Diagnostics\DiagnosticReporter;
use TypePhp\Exception\TestError;
final class NegativeCompatibilityDiagnosticReporter implements DiagnosticReporter
{
/** @var list<string> */
public array $warnings = [];
public function fatal(string $message): never
{
throw new TestError($message);
}
public function warning(Node $node, string $file, string $message): void
{
$this->warnings[] = $message . ' in ' . $file . ':' . $node->getStartLine();
}
}
/**
* Verifies that intentional PHP compatibility boundaries fail in a controlled,
* stable compiler phase instead of warning, crashing, or emitting invalid C++.
* @internal
* @coversNothing
*/
final class NegativeCompatibilityTest extends PHPUnit\Framework\TestCase
{
private string $testRoot;
protected function setUp(): void
{
$this->testRoot = sys_get_temp_dir() . '/typephp-negative-' . bin2hex(random_bytes(8));
mkdir($this->testRoot, 0777, true);
}
protected function tearDown(): void
{
if (!is_dir($this->testRoot)) {
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->testRoot, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $entry) {
if ($entry->isDir()) {
rmdir($entry->getPathname());
} else {
unlink($entry->getPathname());
}
}
rmdir($this->testRoot);
}
/**
* @dataProvider incompatibilityProvider
*/
public function testIntentionalIncompatibilityFailsCleanly(
string $expectedPhase,
string $expectedDiagnostic,
string $source,
): void {
$file = $this->testRoot . '/program.php';
file_put_contents($file, $source);
global $translator;
$compiler = CompilerTest::create($this->testRoot);
$translator = $compiler;
$reporter = new NegativeCompatibilityDiagnosticReporter();
$compiler->setDiagnosticReporter($reporter);
$compiler->addFiles([$file]);
$phpDiagnostics = [];
$failure = null;
$failurePhase = 'prepare';
set_error_handler(static function (
int $severity,
string $message,
string $diagnosticFile,
int $line,
) use (&$phpDiagnostics): bool {
if (!(error_reporting() & $severity)) {
return false;
}
$phpDiagnostics[] = $message . ' in ' . $diagnosticFile . ':' . $line;
return true;
});
try {
$compiler->prepareFile($file);
$failurePhase = 'convert';
$compiler->convertFile($file);
} catch (Throwable $exception) {
$failure = $exception;
} finally {
restore_error_handler();
}
self::assertNotNull($failure, 'Compilation unexpectedly succeeded');
self::assertInstanceOf(
TestError::class,
$failure,
'The compiler boundary must use a controlled diagnostic, not ' . $failure::class,
);
self::assertSame($expectedPhase, $failurePhase, 'The diagnostic was raised in the wrong compiler phase');
self::assertSame(
$expectedDiagnostic . ' in ' . $file . ':' . $this->diagnosticLine($source, $expectedDiagnostic),
$failure->getMessage(),
'The compiler diagnostic changed',
);
self::assertSame([], $reporter->warnings, 'The compiler emitted warnings before failing');
self::assertSame([], $phpDiagnostics, 'PHP emitted a warning/notice before the compiler failed');
self::assertFileDoesNotExist($compiler->getCppFile($file), 'A failed conversion emitted a C++ file');
}
public static function incompatibilityProvider(): iterable
{
yield 'global executable statement' => [
'prepare',
'All execution code must be within a function, found stray code',
"<?php\nprint \"outside main\"; // @diagnostic\n",
];
yield 'variable variables' => [
'convert',
'The `$$` syntax is not supported',
<<<'PHP'
<?php
function main(): void
{
$name = 'value';
$value = 42;
print $$name; // @diagnostic
}
PHP,
];
yield 'closure reference parameter' => [
'convert',
'Closure cannot use reference parameter',
<<<'PHP'
<?php
function main(): void
{
$callback = static function (&$value): void { // @diagnostic
};
}
PHP,
];
yield 'arrow function reference parameter' => [
'convert',
'Closure cannot use reference parameter',
<<<'PHP'
<?php
function main(): void
{
$callback = static fn (&$value): mixed => $value; // @diagnostic
}
PHP,
];
yield 'closure reference return' => [
'convert',
'Closure and arrow functions cannot return by reference',
<<<'PHP'
<?php
function main(): void
{
$callback = static function &(): mixed { // @diagnostic
static $value = 42;
return $value;
};
}
PHP,
];
yield 'arrow function reference return' => [
'convert',
'Closure and arrow functions cannot return by reference',
<<<'PHP'
<?php
function main(): void
{
$value = 42;
$callback = static fn &(): mixed => $value; // @diagnostic
}
PHP,
];
yield 'reference variadic parameter' => [
'prepare',
'Variadic parameters cannot be passed by reference',
<<<'PHP'
<?php
function collect(&...$values): array // @diagnostic
{
return $values;
}
PHP,
];
yield 'ticks declare' => [
'convert',
'declare(ticks=1) is not supported',
<<<'PHP'
<?php
declare(ticks=1); // @diagnostic
function main(): void
{
}
PHP,
];
yield 'non-UTF-8 encoding declare' => [
'convert',
'declare(encoding="ISO-8859-1") is not supported, only UTF-8 is supported',
<<<'PHP'
<?php
declare(encoding='ISO-8859-1'); // @diagnostic
function main(): void
{
}
PHP,
];
yield 'unknown declare directive' => [
'convert',
'declare(custom=1) is not supported',
<<<'PHP'
<?php
declare(custom=1); // @diagnostic
function main(): void
{
}
PHP,
];
yield 'disabled strict types declare' => [
'convert',
'declare(strict_types=0) is not allowed, only strict_types=1 is supported',
<<<'PHP'
<?php
declare(strict_types=0); // @diagnostic
function main(): void
{
}
PHP,
];
yield 'nested match arm condition' => [
'convert',
'Match expression cannot be used as a condition',
<<<'PHP'
<?php
function main(): void
{
$value = 1;
$result = match ($value) {
match ($value) { 1 => 1, default => 0 } => 'nested', // @diagnostic
default => 'default',
};
}
PHP,
];
yield 'foreach reference property target' => [
'convert',
'Foreach by reference only supports variable as value',
<<<'PHP'
<?php
final class Holder
{
public mixed $value = null;
}
function main(): void
{
$holder = new Holder();
$values = [1, 2];
foreach ($values as &$holder->value) { // @diagnostic
}
}
PHP,
];
yield 'foreach reference list destructuring' => [
'convert',
'Foreach list destructuring cannot bind items by reference',
<<<'PHP'
<?php
function main(): void
{
$rows = [[1, 2]];
foreach ($rows as [&$left, &$right]) { // @diagnostic
}
}
PHP,
];
}
private function diagnosticLine(string $source, string $diagnostic): int
{
foreach (explode("\n", $source) as $index => $line) {
if (str_contains($line, '@diagnostic')) {
return $index + 1;
}
}
self::fail('Missing @diagnostic marker for ' . $diagnostic);
}
}

@ -150,6 +150,8 @@ trait ClosureGenerator
$isGenerator = $this->closureContainsYield($expr);
if ($isGenerator) {
$this->validateGeneratorClosure($expr, $params);
} elseif ($expr->byRef) {
$this->fatalError($expr, 'Closure and arrow functions cannot return by reference');
}
$tmpVar = $this->genTmpVarName();

@ -23,6 +23,9 @@ trait ForeachTrait
continue;
}
if ($item instanceof ArrayItem) {
if ($item->byRef) {
$this->fatalError($item, 'Foreach list destructuring cannot bind items by reference');
}
$key = $item->key ? $this->parseArrayKey($item->key) : (string) $k;
if ($item->value instanceof Expr\List_) {
$nestedTmpVar = $this->genTmpVarName();
@ -65,10 +68,6 @@ trait ForeachTrait
$this->fatalError($node, 'Cannot use & with foreach');
}
if ($node->byRef and !$this->isVarExpr($node->valueVar)) {
$this->fatalError($node, 'Foreach by reference only supports variable as value');
}
if ($node->valueVar instanceof Expr\List_) {
if ($node->byRef) {
$this->fatalError($node, 'Foreach by reference cannot use list destructuring');
@ -79,6 +78,10 @@ trait ForeachTrait
. $this->parseForeachItemAsList($listTmpVar, $node->valueVar->items);
}
if ($node->byRef and !$this->isVarExpr($node->valueVar)) {
$this->fatalError($node, 'Foreach by reference only supports variable as value');
}
if ($this->isArrayDimFetch($node->valueVar)) {
if ($node->byRef) {
$this->fatalError($node, 'Foreach by reference only supports variable as value');

Loading…
Cancel
Save