From 6dcafb652bc4c4fc809d47674a86dd76cfff8fe3 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 24 Aug 2026 16:58:14 +0800 Subject: [PATCH] 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 --- docs/INCOMPATIBLE_PHP_FEATURES.md | 3 +- docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md | 3 +- phpunit/src/NegativeCompatibilityTest.php | 320 +++++++++++++++++++++ src/Generator/ClosureGenerator.php | 2 + src/Parser/ForeachTrait.php | 11 +- 5 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 phpunit/src/NegativeCompatibilityTest.php diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 380f3975..c96d28aa 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -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 的变量删除语义。 diff --git a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md index 86bad89d..800d0ecb 100644 --- a/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md +++ b/docs/PHP_INCOMPATIBILITY_CLASSIFICATION.md @@ -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 diff --git a/phpunit/src/NegativeCompatibilityTest.php b/phpunit/src/NegativeCompatibilityTest.php new file mode 100644 index 00000000..eec5512e --- /dev/null +++ b/phpunit/src/NegativeCompatibilityTest.php @@ -0,0 +1,320 @@ + */ + 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', + " [ + 'convert', + 'The `$$` syntax is not supported', + <<<'PHP' + [ + 'convert', + 'Closure cannot use reference parameter', + <<<'PHP' + [ + 'convert', + 'Closure cannot use reference parameter', + <<<'PHP' + $value; // @diagnostic +} +PHP, + ]; + + yield 'closure reference return' => [ + 'convert', + 'Closure and arrow functions cannot return by reference', + <<<'PHP' + [ + 'convert', + 'Closure and arrow functions cannot return by reference', + <<<'PHP' + $value; // @diagnostic +} +PHP, + ]; + + yield 'reference variadic parameter' => [ + 'prepare', + 'Variadic parameters cannot be passed by reference', + <<<'PHP' + [ + 'convert', + 'declare(ticks=1) is not supported', + <<<'PHP' + [ + 'convert', + 'declare(encoding="ISO-8859-1") is not supported, only UTF-8 is supported', + <<<'PHP' + [ + 'convert', + 'declare(custom=1) is not supported', + <<<'PHP' + [ + 'convert', + 'declare(strict_types=0) is not allowed, only strict_types=1 is supported', + <<<'PHP' + [ + 'convert', + 'Match expression cannot be used as a condition', + <<<'PHP' + 1, default => 0 } => 'nested', // @diagnostic + default => 'default', + }; +} +PHP, + ]; + + yield 'foreach reference property target' => [ + 'convert', + 'Foreach by reference only supports variable as value', + <<<'PHP' +value) { // @diagnostic + } +} +PHP, + ]; + + yield 'foreach reference list destructuring' => [ + 'convert', + 'Foreach list destructuring cannot bind items by reference', + <<<'PHP' + $line) { + if (str_contains($line, '@diagnostic')) { + return $index + 1; + } + } + self::fail('Missing @diagnostic marker for ' . $diagnostic); + } +} diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index 17be0d75..3ed87898 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -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(); diff --git a/src/Parser/ForeachTrait.php b/src/Parser/ForeachTrait.php index ec7841df..4180ada1 100644 --- a/src/Parser/ForeachTrait.php +++ b/src/Parser/ForeachTrait.php @@ -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');