- 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++ emissionmaster
parent
23a2bf44d7
commit
6dcafb652b
5 changed files with 331 additions and 8 deletions
@ -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); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue