*/ 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' [ 'prepare', 'Property get hooks returning by reference are not supported', <<<'PHP' $this->value; // @diagnostic } } PHP, ]; yield 'arrow function reference return' => [ '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' [ 'prepare', 'The (void) cast can only be used as a statement', <<<'PHP' [ 'prepare', 'The (void) cast can only be used as a statement', <<<'PHP' [ 'prepare', 'The (void) cast can only be used as a statement', <<<'PHP' [ 'prepare', 'The (void) cast can only be used as a statement', <<<'PHP' [ 'prepare', 'The (void) cast can only be used as a statement', <<<'PHP' $line) { if (str_contains($line, '@diagnostic')) { return $index + 1; } } self::fail('Missing @diagnostic marker for ' . $diagnostic); } }