- 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