From 19a570ff3f9cfca3477b45e1245c7e1307d59c6e Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Tue, 25 Aug 2026 11:43:23 +0800 Subject: [PATCH] feat(testing): add test coverage analyzer tool - Implement bin/analyze-test-coverage.php command line tool for coverage analysis - Add TestCoverageAnalyzer class to generate PHP version x feature x evidence matrix - Create markdown and JSON output formats for coverage reports - Integrate AST node coverage with explicit denominators from php-parser - Add strict mode for CI validation of parse issues and fixture references - Update ClassTest.php to reflect new object-to-array conversion behavior - Modify MethodCallTrait to remove fatal error on missing toArray methods - Add documentation for test coverage analyzer usage and format - Include package-lock.json with sharp dependency for image processing --- bin/analyze-test-coverage.php | 146 ++ docs/TEST_COVERAGE_ANALYZER.md | 48 + package-lock.json | 116 ++ phpunit/src/ClassTest.php | 8 +- .../src/Testing/TestCoverageAnalyzerTest.php | 146 ++ src/Parser/MethodCallTrait.php | 19 +- src/Parser/UniversalMethodCall.php | 2 +- src/Testing/TestCoverageAnalyzer.php | 1220 +++++++++++++++++ tests/compiler/RUN_TESTS_GUIDE.md | 25 +- .../toarray-dynamic-method.phpt | 45 +- 10 files changed, 1730 insertions(+), 45 deletions(-) create mode 100755 bin/analyze-test-coverage.php create mode 100644 docs/TEST_COVERAGE_ANALYZER.md create mode 100644 package-lock.json create mode 100644 phpunit/src/Testing/TestCoverageAnalyzerTest.php create mode 100644 src/Testing/TestCoverageAnalyzer.php diff --git a/bin/analyze-test-coverage.php b/bin/analyze-test-coverage.php new file mode 100755 index 00000000..39a3ae66 --- /dev/null +++ b/bin/analyze-test-coverage.php @@ -0,0 +1,146 @@ +#!/usr/bin/env php +analyze( + $paths, + $includePhpUnit ? ROOT_PATH . '/phpunit/src' : null, + $includePhpUnit ? ROOT_PATH . '/phpunit/code' : null, + ); +} catch (Throwable $error) { + fwrite(STDERR, 'Coverage analysis failed: ' . $error->getMessage() . PHP_EOL); + exit(1); +} + +$rendered = match ($format) { + 'summary' => $analyzer->renderSummary($report), + 'markdown' => $analyzer->renderMarkdown($report), + 'json' => json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL, +}; + +if ($output === null) { + echo $rendered; +} else { + $outputPath = isAbsolutePath($output) ? $output : ROOT_PATH . DIRECTORY_SEPARATOR . $output; + $directory = dirname($outputPath); + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + fwrite(STDERR, 'Unable to create output directory: ' . $directory . PHP_EOL); + exit(1); + } + if (file_put_contents($outputPath, $rendered) === false) { + fwrite(STDERR, 'Unable to write report: ' . $outputPath . PHP_EOL); + exit(1); + } + echo 'Wrote ', $format, ' coverage report: ', relativePath(ROOT_PATH, $outputPath), PHP_EOL; +} + +if ($strict && ($report['parse_errors'] !== [] || $report['unresolved_phpunit_fixtures'] !== [])) { + exit(1); +} + +function printUsage(string $script): void +{ + echo << Write the report to a file + --php-versions=8.4,8.5 Target PHP version columns + --no-phpunit Do not scan PHPUnit compiler fixtures + --strict Fail on parse issues or unresolved fixture links + -h, --help Show this help + +Examples: + php {$script} + php {$script} --format=markdown --output=build/test-coverage.md + php {$script} --format=json tests/compiler/type_decl tests/compiler/basic + +The tool reports separate, explicitly denominated AST-node, positive compile, +runtime semantic and negative diagnostic coverage. It never emits a combined +overall percentage. + +USAGE; +} + +function isAbsolutePath(string $path): bool +{ + return $path !== '' && ($path[0] === '/' || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1); +} + +function relativePath(string $root, string $path): string +{ + $prefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : $path; +} diff --git a/docs/TEST_COVERAGE_ANALYZER.md b/docs/TEST_COVERAGE_ANALYZER.md new file mode 100644 index 00000000..638a8be8 --- /dev/null +++ b/docs/TEST_COVERAGE_ANALYZER.md @@ -0,0 +1,48 @@ +# 测试覆盖清单 + +`bin/analyze-test-coverage.php` 从 PHPT 和编译器 PHPUnit fixture 的源码生成覆盖清单。它是静态测试意图分析工具,不替代测试执行。 + +## 使用 + +```bash +# 终端摘要 +php bin/analyze-test-coverage.php + +# 可审阅的完整矩阵 +php bin/analyze-test-coverage.php \ + --format=markdown \ + --output=build/test-coverage.md + +# 供 CI 或其他工具读取 +php bin/analyze-test-coverage.php \ + --format=json \ + --output=build/test-coverage.json \ + --strict +``` + +默认扫描 `tests/compiler`、`phpunit/src` 和 `phpunit/code`。也可以在命令末尾传入一个或多个 PHPT 文件或目录;`--no-phpunit` 只分析 PHPT,`--php-versions=8.4,8.5` 设置矩阵的 PHP 版本列。 + +`--strict` 在存在非预期的源码解析失败或无法解析的 PHPUnit fixture 引用时返回非零状态。负向数据提供器中故意不能被 php-parser 接受的样本会单独记入 `expected_parser_diagnostics`,不会伪装成工具故障。 + +## 三类覆盖证据 + +每个适用的 `PHP 版本 × 特性` 行分别记录: + +- `positive_compile`:有效 PHPT,或正向 PHPUnit 编译 fixture; +- `runtime_semantics`:含 `EXPECT`、`EXPECTF` 或 `EXPECTREGEX` 的有效 PHPT; +- `negative_diagnostic`:期待诊断的 PHPT,或明确期待失败的 PHPUnit 测试/数据提供器。 + +`XFAIL` 和无条件 `SKIPIF` 不计入任何证据轴。PHP 版本范围从测试标题、`SKIPIF` 中的 `PHP_VERSION_ID` 条件以及 PHPUnit 数据行中的版本字符串推断。 + +## 分母 + +报告只给出带明确分母的比率: + +- AST 节点覆盖分母:当前安装的 `nikic/php-parser` 所提供的具体 AST 节点种类;用于错误恢复的 `Expr_Error` 不计入。 +- 特性轴覆盖分母:特性目录中 `introduced <= 目标 PHP 版本` 的行数。每个正向编译、运行语义和负向诊断轴独立计算。 + +工具不会把不同含义的三个轴合成一个“项目总覆盖率”。完整 JSON 同时保留特性目录、逐项证据来源、矩阵、AST 节点出现次数、解析问题和排除原因,便于 CI 进一步检查。 + +## 分类边界 + +AST 节点由 parser 自动提取。无法只靠节点区分的语义特性(例如 DNF 出现位置、属性 hook 变体、`exit(message: ...)`)由分析器中的显式特性目录补充。新增语言特性时应同时登记其引入版本和检测规则,以维持版本矩阵的明确分母。 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9fc652a7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,116 @@ +{ + "name": "typephp-compiler", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "typephp-compiler", + "version": "1.0.0", + "license": "GPL-3.0-or-later", + "dependencies": { + "sharp": "^0.34.5" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "7.8.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + } + } +} diff --git a/phpunit/src/ClassTest.php b/phpunit/src/ClassTest.php index f83be81c..5ddef047 100644 --- a/phpunit/src/ClassTest.php +++ b/phpunit/src/ClassTest.php @@ -20,16 +20,12 @@ class ClassTest extends \BaseTest $this->compile('zend-class-to-array-return-type.php'); } - public function testKnownZendClassMustDefineToArray(): void + public function testKnownZendClassWithoutToArrayUsesPropertyFallback(): void { - $this->expectException(\TypePhp\Exception\TestError::class); - $this->expectExceptionMessage( - 'Class `ZendWithoutToArray` must define `toArray()` for this conversion', - ); $this->compile('zend-class-to-array-missing.php'); } - public function testKnownZendClassMayResolveToArrayThroughMagicCall(): void + public function testKnownZendClassWithMagicCallStillUsesArrayConversion(): void { $this->compile('zend-class-to-array-magic.php'); } diff --git a/phpunit/src/Testing/TestCoverageAnalyzerTest.php b/phpunit/src/Testing/TestCoverageAnalyzerTest.php new file mode 100644 index 00000000..7d9599c2 --- /dev/null +++ b/phpunit/src/Testing/TestCoverageAnalyzerTest.php @@ -0,0 +1,146 @@ +testRoot = sys_get_temp_dir() . '/typephp-coverage-' . bin2hex(random_bytes(8)); + mkdir($this->testRoot . '/phpt', 0777, true); + mkdir($this->testRoot . '/phpunit-src', 0777, true); + mkdir($this->testRoot . '/phpunit-code', 0777, true); + } + + protected function tearDown(): void + { + if (!is_dir($this->testRoot)) { + return; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->testRoot, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($this->testRoot); + } + + public function testBuildsVersionedEvidenceMatrixFromPhptAndPhpUnitSources(): void + { + file_put_contents($this->testRoot . '/phpt/void-cast.phpt', <<<'PHPT' +--TEST-- +PHP 8.5 void cast runtime semantics +--FILE-- +testRoot . '/phpt/negative-named-exit.phpt', <<<'PHPT' +--TEST-- +PHP 8.4 invalid named exit argument fails cleanly +--FILE-- +testRoot . '/phpunit-src/CoverageFixtureTest.php', <<<'PHP' +expectException(\RuntimeException::class); + } + + public static function invalidProvider(): iterable + { + yield [ + ' $this->value; } }', + '8.4', + ]; + } +} +PHP); + + $analyzer = new TestCoverageAnalyzer(ROOT_PATH, ['8.4', '8.5']); + $report = $analyzer->analyze( + [$this->testRoot . '/phpt'], + $this->testRoot . '/phpunit-src', + $this->testRoot . '/phpunit-code', + ); + + self::assertSame(2, $report['summary']['phpt_files']); + self::assertSame(2, $report['summary']['parsed_phpt_files']); + self::assertSame([], $report['parse_errors']); + self::assertSame([], $report['unresolved_phpunit_fixtures']); + self::assertArrayNotHasKey('overall_percentage', $report['summary']); + + $void85 = $this->matrixRow($report, '8.5', 'semantic:void_cast'); + self::assertTrue($void85['positive_compile']); + self::assertTrue($void85['runtime_semantics']); + self::assertFalse($void85['negative_diagnostic']); + self::assertNull($this->findMatrixRow($report, '8.4', 'semantic:void_cast')); + + $exit84 = $this->matrixRow($report, '8.4', 'semantic:exit_named_argument'); + self::assertFalse($exit84['positive_compile']); + self::assertFalse($exit84['runtime_semantics']); + self::assertTrue($exit84['negative_diagnostic']); + + $hook84 = $this->matrixRow($report, '8.4', 'semantic:property_hook_by_reference'); + self::assertFalse($hook84['positive_compile']); + self::assertTrue($hook84['negative_diagnostic']); + + $markdown = $analyzer->renderMarkdown($report); + self::assertStringContainsString('PHP version × feature × evidence matrix', $markdown); + self::assertStringContainsString('No combined overall percentage is calculated.', $markdown); + } + + /** @param array $report @return array */ + private function matrixRow(array $report, string $version, string $featureId): array + { + $row = $this->findMatrixRow($report, $version, $featureId); + self::assertNotNull($row, $version . ' ' . $featureId); + return $row; + } + + /** @param array $report @return array|null */ + private function findMatrixRow(array $report, string $version, string $featureId): ?array + { + foreach ($report['matrix'] as $row) { + if ($row['php_version'] === $version && $row['feature_id'] === $featureId) { + return $row; + } + } + return null; + } +} diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 730b7e6d..b2494dd6 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -549,25 +549,12 @@ trait MethodCallTrait if ($receiverClass === '' && !$this->isVarExpr($expr->var)) { $receiverClass = $this->detectClassOfExpr($expr->var); } - // A statically known object method preserves keyword priority - // while avoiding the generic PHPX conversion helper. + // A declared conversion method is called directly. Otherwise + // php::toArray() applies the PHP-compatible object-property + // fallback (and invokes a real toArray() method when present). $useDeclaredToArray = $methodName === 'toArray' && $receiverClass !== '' && $this->objectTypeDeclaresMethod($receiverClass, $methodName); - $useMagicToArray = $methodName === 'toArray' - && $receiverClass !== '' - && $this->objectTypeDeclaresMethod($receiverClass, '__call'); - if ($methodName === 'toArray' - && $receiverClass !== '' - && !$useDeclaredToArray - && !$useMagicToArray - && ($this->hasClass($receiverClass) || $this->isInternalClass($receiverClass)) - ) { - $this->fatalError( - $expr, - "Class `{$receiverClass}` must define `toArray()` for this conversion", - ); - } if (!$useDeclaredToArray) { return $this->genToConvertCall($object, $methodName, $receiverType); } diff --git a/src/Parser/UniversalMethodCall.php b/src/Parser/UniversalMethodCall.php index 310c8e52..03b18b0a 100644 --- a/src/Parser/UniversalMethodCall.php +++ b/src/Parser/UniversalMethodCall.php @@ -448,7 +448,7 @@ trait UniversalMethodCall 'toFloat' => 'php::toFloat(' . $receiver . ')', 'toString' => 'php::toString(' . $receiver . ')', 'toBool' => 'php::toBool(' . $receiver . ')', - 'toArray' => 'php::callToArray(' . $receiver . ')', + 'toArray' => 'php::toArray(' . $receiver . ')', 'toStream' => 'php::toStream(' . $receiver . ')', 'toBigInt' => 'php::BigInt::newInstance(' . $receiver . ')', 'toBigFloat' => 'php::BigFloat::newInstance(' . $receiver . ')', diff --git a/src/Testing/TestCoverageAnalyzer.php b/src/Testing/TestCoverageAnalyzer.php new file mode 100644 index 00000000..fb896d63 --- /dev/null +++ b/src/Testing/TestCoverageAnalyzer.php @@ -0,0 +1,1220 @@ +> */ + private array $catalog; + + /** @var array>>> */ + private array $evidence = []; + + /** @var list> */ + private array $tests = []; + + /** @var list */ + private array $parseErrors = []; + + /** @var list */ + private array $expectedParserDiagnostics = []; + + /** @var list */ + private array $unresolvedPhpUnitFixtures = []; + private int $discoveredPhptFiles = 0; + + /** @param list $targetPhpVersions */ + public function __construct( + private readonly string $projectRoot, + private readonly array $targetPhpVersions = ['8.4', '8.5'], + ) { + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); + $this->catalog = $this->buildFeatureCatalog(); + $this->resetEvidence(); + } + + /** + * @param list $phptPaths + * @return array + */ + public function analyze( + array $phptPaths, + ?string $phpUnitSourceDirectory = null, + ?string $phpUnitFixtureDirectory = null, + ): array { + $this->resetEvidence(); + $this->tests = []; + $this->parseErrors = []; + $this->expectedParserDiagnostics = []; + $this->unresolvedPhpUnitFixtures = []; + + $phptFiles = $this->discoverFiles($phptPaths, '.phpt'); + $this->discoveredPhptFiles = count($phptFiles); + foreach ($phptFiles as $file) { + $this->analyzePhpt($file); + } + + if ($phpUnitSourceDirectory !== null && $phpUnitFixtureDirectory !== null) { + $this->analyzePhpUnit($phpUnitSourceDirectory, $phpUnitFixtureDirectory); + } + + return $this->buildReport($phptPaths, $phpUnitSourceDirectory, $phpUnitFixtureDirectory); + } + + /** @param array $report */ + public function renderSummary(array $report): string + { + $lines = []; + $lines[] = 'TypePHP test coverage inventory'; + $lines[] = str_repeat('=', 32); + $lines[] = sprintf( + 'Sources: %d PHPT, %d resolved PHPUnit fixture links, %d parsed source records', + $report['summary']['phpt_files'], + $report['summary']['phpunit_fixture_links'], + $report['summary']['parsed_source_records'], + ); + if ($report['summary']['parsed_phpt_files'] !== $report['summary']['phpt_files']) { + $lines[] = sprintf( + 'Parsed PHPT sources: %d/%d', + $report['summary']['parsed_phpt_files'], + $report['summary']['phpt_files'], + ); + } + $lines[] = sprintf( + 'Parse issues: %d; unresolved PHPUnit fixture references: %d', + count($report['parse_errors']), + count($report['unresolved_phpunit_fixtures']), + ); + $lines[] = 'Expected parser diagnostics in negative providers: ' + . count($report['expected_parser_diagnostics']); + $lines[] = ''; + $ast = $report['summary']['ast_node_coverage']; + $lines[] = sprintf( + 'AST node-kind coverage: %d/%d (%.1f%%)', + $ast['covered'], + $ast['denominator'], + $ast['percentage'], + ); + $lines[] = ' denominator = concrete AST node kinds shipped by the installed php-parser'; + $lines[] = ''; + $lines[] = 'Feature-axis coverage (each denominator is the applicable feature rows for that PHP version):'; + foreach ($report['summary']['coverage_by_php_version'] as $version => $coverage) { + $lines[] = ' PHP ' . $version . ' (' . $coverage['applicable_features'] . ' feature rows)'; + foreach (self::AXES as $axis) { + $metric = $coverage[$axis]; + $lines[] = sprintf( + ' %-20s %d/%d (%.1f%%)', + $axis . ':', + $metric['covered'], + $metric['denominator'], + $metric['percentage'], + ); + } + } + $lines[] = ''; + $lines[] = 'No combined overall percentage is calculated.'; + $lines[] = 'Use --format=markdown or --format=json for the complete PHP version × feature matrix.'; + return implode(PHP_EOL, $lines) . PHP_EOL; + } + + /** @param array $report */ + public function renderMarkdown(array $report): string + { + $lines = [ + '# TypePHP test coverage inventory', + '', + '> This report is generated from test sources. It records static test intent/evidence; it is not a test execution result.', + '', + '## Scope and denominators', + '', + '| Item | Value |', + '|------|------:|', + '| PHPT files | ' . $report['summary']['phpt_files'] . ' |', + '| Parsed PHPT files | ' . $report['summary']['parsed_phpt_files'] . ' |', + '| Resolved PHPUnit fixture links | ' . $report['summary']['phpunit_fixture_links'] . ' |', + '| Parsed source records | ' . $report['summary']['parsed_source_records'] . ' |', + '| AST node-kind denominator | ' . $report['summary']['ast_node_coverage']['denominator'] . ' |', + '| AST node kinds covered | ' . $report['summary']['ast_node_coverage']['covered'] . ' |', + '| Parse issues | ' . count($report['parse_errors']) . ' |', + '| Expected parser diagnostics | ' . count($report['expected_parser_diagnostics']) . ' |', + '', + 'The AST denominator is the set of concrete AST node kinds shipped by the installed `nikic/php-parser`; `Expr_Error` is excluded because it represents parser recovery rather than a testable syntax feature. The feature-axis denominator is the number of catalog rows applicable to each target PHP version.', + '', + 'No combined overall percentage is calculated.', + '', + '## Coverage by PHP version and evidence axis', + '', + '| PHP | Applicable feature rows | Positive compile | Runtime semantics | Negative diagnostic |', + '|-----|------------------------:|-----------------:|------------------:|--------------------:|', + ]; + + foreach ($report['summary']['coverage_by_php_version'] as $version => $coverage) { + $lines[] = sprintf( + '| %s | %d | %s | %s | %s |', + $version, + $coverage['applicable_features'], + $this->formatRatio($coverage['positive_compile']), + $this->formatRatio($coverage['runtime_semantics']), + $this->formatRatio($coverage['negative_diagnostic']), + ); + } + + $lines[] = ''; + $lines[] = '## PHP version × feature × evidence matrix'; + $lines[] = ''; + $lines[] = '| PHP | Feature | Category | Positive compile | Runtime semantics | Negative diagnostic |'; + $lines[] = '|-----|---------|----------|:----------------:|:-----------------:|:-------------------:|'; + foreach ($report['matrix'] as $row) { + $lines[] = sprintf( + '| %s | `%s` | %s | %s | %s | %s |', + $row['php_version'], + str_replace('|', '\|', $row['feature']), + $row['category'], + $row['positive_compile'] ? 'yes' : '—', + $row['runtime_semantics'] ? 'yes' : '—', + $row['negative_diagnostic'] ? 'yes' : '—', + ); + } + + $lines[] = ''; + $lines[] = '## AST node occurrences'; + $lines[] = ''; + $lines[] = '| AST node | Active test sources |'; + $lines[] = '|----------|--------------------:|'; + foreach ($report['ast_nodes'] as $node) { + $lines[] = '| `' . $node['node_type'] . '` | ' . $node['active_test_sources'] . ' |'; + } + + if ($report['parse_errors'] !== []) { + $lines[] = ''; + $lines[] = '## Parse issues'; + $lines[] = ''; + foreach ($report['parse_errors'] as $error) { + $lines[] = '- `' . $error['file'] . '`: ' . str_replace("\n", ' ', $error['message']); + } + } + + return implode(PHP_EOL, $lines) . PHP_EOL; + } + + /** @return array> */ + private function buildFeatureCatalog(): array + { + $catalog = []; + $nodeDirectory = $this->projectRoot . '/vendor/nikic/php-parser/lib/PhpParser/Node'; + if (!is_dir($nodeDirectory)) { + throw new \RuntimeException('php-parser node directory not found: ' . $nodeDirectory); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($nodeDirectory, \FilesystemIterator::SKIP_DOTS), + ); + $seenClasses = []; + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $relative = substr($file->getPathname(), strlen($nodeDirectory) + 1, -4); + $class = 'PhpParser\Node\\' . str_replace(DIRECTORY_SEPARATOR, '\\', $relative); + if (!class_exists($class)) { + continue; + } + $reflection = new \ReflectionClass($class); + $canonicalClass = $reflection->getName(); + if (isset($seenClasses[$canonicalClass]) || $reflection->isAbstract() || !$reflection->isSubclassOf(Node::class)) { + continue; + } + $seenClasses[$canonicalClass] = true; + try { + /** @var Node $node */ + $node = $reflection->newInstanceWithoutConstructor(); + $nodeType = $node->getType(); + } catch (\Throwable) { + continue; + } + if ($nodeType === 'Expr_Error') { + continue; + } + $catalog['ast:' . $nodeType] = [ + 'id' => 'ast:' . $nodeType, + 'label' => $nodeType, + 'category' => 'ast', + 'introduced' => $this->astNodeIntroducedVersion($nodeType), + 'node_type' => $nodeType, + ]; + } + + foreach ($this->semanticFeatureDefinitions() as $id => [$label, $version]) { + $catalog['semantic:' . $id] = [ + 'id' => 'semantic:' . $id, + 'label' => $label, + 'category' => 'semantic', + 'introduced' => $version, + 'node_type' => null, + ]; + } + + uasort($catalog, static function (array $left, array $right): int { + return [$left['introduced'], $left['category'], $left['label']] + <=> [$right['introduced'], $right['category'], $right['label']]; + }); + return $catalog; + } + + /** @return array */ + private function semanticFeatureDefinitions(): array + { + return [ + 'alternative_control_syntax' => ['alternative_control_syntax', '7.4'], + 'numeric_literal_binary' => ['numeric_literal_binary', '7.4'], + 'numeric_literal_explicit_octal' => ['numeric_literal_explicit_octal', '8.1'], + 'numeric_literal_separator' => ['numeric_literal_separator', '7.4'], + 'relative_namespace_name' => ['relative_namespace_name', '7.4'], + 'named_arguments' => ['named_arguments', '8.0'], + 'constructor_property_promotion' => ['constructor_property_promotion', '8.0'], + 'class_constant_attributes' => ['class_constant_attributes', '8.0'], + 'readonly_property' => ['readonly_property', '8.1'], + 'first_class_callable' => ['first_class_callable', '8.1'], + 'dnf_types' => ['dnf_types', '8.2'], + 'dnf_parameter' => ['dnf_parameter', '8.2'], + 'dnf_return' => ['dnf_return', '8.2'], + 'dnf_property' => ['dnf_property', '8.2'], + 'dnf_closure_signature' => ['dnf_closure_signature', '8.2'], + 'readonly_class' => ['readonly_class', '8.2'], + 'property_hooks' => ['property_hooks', '8.4'], + 'property_hook_get' => ['property_hook_get', '8.4'], + 'property_hook_set' => ['property_hook_set', '8.4'], + 'property_hook_by_reference' => ['property_hook_by_reference', '8.4'], + 'final_property_hook' => ['final_property_hook', '8.4'], + 'asymmetric_property_visibility' => ['asymmetric_property_visibility', '8.4'], + 'promoted_asymmetric_property' => ['promoted_asymmetric_property', '8.4'], + 'final_property' => ['final_property', '8.4'], + 'exit_named_argument' => ['exit_named_argument', '8.4'], + 'property_magic_constant' => ['property_magic_constant', '8.4'], + 'void_cast' => ['void_cast', '8.5'], + 'pipe_operator' => ['pipe_operator', '8.5'], + 'clone_with' => ['clone_with', '8.5'], + 'global_constant_attributes' => ['global_constant_attributes', '8.5'], + 'constant_closure' => ['constant_closure', '8.5'], + 'closure_default_value' => ['closure_default_value', '8.5'], + ]; + } + + private function astNodeIntroducedVersion(string $nodeType): string + { + return match ($nodeType) { + 'Attribute', 'AttributeGroup', 'Expr_Match', 'MatchArm', + 'Expr_NullsafeMethodCall', 'Expr_NullsafePropertyFetch', 'UnionType' => '8.0', + 'Stmt_Enum', 'Stmt_EnumCase', 'IntersectionType', 'VariadicPlaceholder' => '8.1', + 'PropertyHook', 'Scalar_MagicConst_Property' => '8.4', + 'Expr_Cast_Void', 'Expr_BinaryOp_Pipe' => '8.5', + default => '7.4', + }; + } + + private function resetEvidence(): void + { + $this->evidence = []; + foreach ($this->catalog as $id => $_) { + foreach (self::AXES as $axis) { + $this->evidence[$id][$axis] = []; + } + } + } + + private function analyzePhpt(string $file): void + { + $contents = file_get_contents($file); + if (!is_string($contents)) { + $this->parseErrors[] = ['file' => $this->relativePath($file), 'message' => 'Unable to read PHPT']; + return; + } + $sections = $this->parsePhptSections($contents); + $code = $sections['FILE'] ?? null; + if (!is_string($code)) { + $this->parseErrors[] = ['file' => $this->relativePath($file), 'message' => 'Missing --FILE-- section']; + return; + } + + $testId = $this->relativePath($file); + $title = trim($sections['TEST'] ?? basename($file)); + [$minPhp, $maxPhp] = $this->inferPhpVersionRange($title, $sections['SKIPIF'] ?? ''); + $excludedReason = null; + if (isset($sections['XFAIL'])) { + $excludedReason = 'xfail'; + } elseif ($this->isUnconditionallySkipped($sections['SKIPIF'] ?? '')) { + $excludedReason = 'unconditional_skip'; + } + + $features = $this->parseSourceFeatures($code, $testId); + $expected = $sections['EXPECT'] ?? $sections['EXPECTF'] ?? $sections['EXPECTREGEX'] ?? null; + $negative = is_string($expected) && $this->looksLikeDiagnosticTest($title, $expected, $code); + $record = [ + 'id' => $testId, + 'kind' => 'phpt', + 'title' => $title, + 'min_php' => $minPhp, + 'max_php' => $maxPhp, + 'excluded_reason' => $excludedReason, + 'features' => array_keys($features), + ]; + $this->tests[] = $record; + + if ($excludedReason !== null || $features === []) { + return; + } + if ($negative) { + $this->addEvidence($features, 'negative_diagnostic', $record); + return; + } + $this->addEvidence($features, 'positive_compile', $record); + if ($expected !== null) { + $this->addEvidence($features, 'runtime_semantics', $record); + } + } + + /** @return array */ + private function parsePhptSections(string $contents): array + { + $parts = preg_split('/^--([A-Z_]+)--\R/m', $contents, -1, PREG_SPLIT_DELIM_CAPTURE); + if (!is_array($parts)) { + return []; + } + $sections = []; + for ($i = 1, $count = count($parts); $i + 1 < $count; $i += 2) { + $sections[$parts[$i]] = $parts[$i + 1]; + } + return $sections; + } + + /** @return array{0: string|null, 1: string|null} */ + private function inferPhpVersionRange(string $title, string $skipIf): array + { + $min = null; + $max = null; + if (preg_match('/\bPHP\s+(8\.[0-9]+)\b/i', $title, $match)) { + $min = $match[1]; + } + if (preg_match_all('/PHP_VERSION_ID\s*<\s*(\d+)/i', $skipIf, $matches)) { + foreach ($matches[1] as $versionId) { + $min = $this->maxVersion($min, $this->versionIdToMinor((int) $versionId)); + } + } + if (preg_match_all('/PHP_VERSION_ID\s*>=\s*(\d+)/i', $skipIf, $matches)) { + foreach ($matches[1] as $versionId) { + $max = $this->minVersion($max, $this->previousMinor($this->versionIdToMinor((int) $versionId))); + } + } + return [$min, $max]; + } + + private function isUnconditionallySkipped(string $skipIf): bool + { + $body = preg_replace('/^\s*<\?php|\?>\s*$/', '', trim($skipIf)); + if (!is_string($body) || $body === '') { + return false; + } + return preg_match('/^\s*(?:die|exit)\s*\(/i', $body) === 1; + } + + private function looksLikeDiagnosticTest(string $title, string $expected, string $code): bool + { + if (preg_match('/(?:Fatal error|Parse error|Compile Error|Uncaught |Warning:|Deprecated:|Notice:)/i', $expected)) { + return true; + } + return preg_match('/\b(error|invalid|reject|forbid|unsupported|cannot|failure)\b/i', $title) === 1 + && preg_match('/\bcatch\s*\(/i', $code) === 1; + } + + private function analyzePhpUnit(string $sourceDirectory, string $fixtureDirectory): void + { + $testFiles = $this->discoverFiles([$sourceDirectory], 'Test.php'); + foreach ($testFiles as $testFile) { + $contents = file_get_contents($testFile); + if (!is_string($contents)) { + continue; + } + try { + $stmts = $this->parser->parse($contents) ?? []; + } catch (\Throwable $error) { + $this->parseErrors[] = ['file' => $this->relativePath($testFile), 'message' => $error->getMessage()]; + continue; + } + $methods = $this->findTestMethods($stmts); + $allMethods = $this->findClassMethods($stmts); + $providers = []; + foreach ($allMethods as $candidateMethod) { + $providers[$candidateMethod->name->toString()] = $this->collectProviderRows($candidateMethod); + } + + foreach ($methods as $method) { + $methodId = $this->relativePath($testFile) . '::' . $method->name->toString(); + $calls = $this->collectMethodCalls($method->stmts ?? []); + $providerName = $this->findDataProviderName($method); + $providerRows = $providerName === null ? [] : ($providers[$providerName] ?? []); + $hasExpectedException = false; + $convertsAnotherLanguage = false; + foreach ($calls as $call) { + if (in_array($call['name'], ['expectException', 'expectExceptionMessage'], true)) { + $hasExpectedException = true; + } + if (in_array($call['name'], ['convert', 'convertSource'], true)) { + $convertsAnotherLanguage = true; + } + } + $hasCompilerFixtureCall = false; + foreach ($calls as $call) { + if (!in_array($call['name'], ['compile', 'exec', 'assertCompiles', 'compileNativeWithReporter'], true)) { + continue; + } + $hasCompilerFixtureCall = true; + $fixtures = []; + $literalFixture = $this->findPhpFixtureArgument($call['node']); + if ($literalFixture !== null) { + $fixtures[] = $literalFixture; + } else { + $fixtures = array_merge( + $this->findPhpFixtureStrings($method), + $this->findPhpFixtureStrings($providerRows), + ); + $fixtures = array_values(array_unique($fixtures)); + } + if ($fixtures === []) { + $this->unresolvedPhpUnitFixtures[] = ['test' => $methodId, 'fixture' => '']; + continue; + } + foreach ($fixtures as $fixture) { + $this->recordPhpUnitFixture( + $methodId, + $method, + $fixtureDirectory, + $fixture, + $call['name'] === 'exec' || $hasExpectedException, + ); + } + } + + if ($hasCompilerFixtureCall || $providerRows === [] || $convertsAnotherLanguage) { + continue; + } + $negativeProvider = $hasExpectedException + || preg_match('/(?:invalid|reject|incompatib|negative|unsupported|error)/i', $method->name->toString()) === 1; + foreach ($providerRows as $index => $row) { + $source = $this->findInlinePhpSource($row); + if ($source === null) { + continue; + } + if (!str_contains($source, ' provider row ' . $index; + $features = $this->parseSourceFeatures($source, $inlineId, $negativeProvider); + if ($features === []) { + continue; + } + $phpVersion = $this->findPhpVersionString($row); + $record = [ + 'id' => $inlineId, + 'kind' => 'phpunit_inline_source', + 'title' => $method->name->toString(), + 'min_php' => $phpVersion, + 'max_php' => $phpVersion, + 'excluded_reason' => null, + 'features' => array_keys($features), + 'negative' => $negativeProvider, + ]; + $this->tests[] = $record; + if ($negativeProvider) { + $this->addEvidence($features, 'negative_diagnostic', $record); + } else { + $this->addEvidence($features, 'positive_compile', $record); + } + } + } + } + } + + /** @param Node[] $nodes @return list */ + private function findTestMethods(array $nodes): array + { + return array_values(array_filter( + $this->findClassMethods($nodes), + static fn (Node\Stmt\ClassMethod $method): bool => str_starts_with($method->name->toString(), 'test'), + )); + } + + /** @param Node[] $nodes @return list */ + private function findClassMethods(array $nodes): array + { + $methods = []; + $this->walkValues($nodes, static function (Node $node) use (&$methods): void { + if ($node instanceof Node\Stmt\ClassMethod) { + $methods[] = $node; + } + }); + return $methods; + } + + /** @param Node[] $nodes @return list */ + private function collectMethodCalls(array $nodes): array + { + $calls = []; + $this->walkValues($nodes, static function (Node $node) use (&$calls): void { + if ($node instanceof Expr\MethodCall && $node->name instanceof Node\Identifier) { + $calls[] = ['name' => $node->name->toString(), 'node' => $node]; + } + }); + return $calls; + } + + private function findPhpFixtureArgument(Expr\MethodCall $call): ?string + { + foreach (array_reverse($call->getArgs()) as $argument) { + if ($argument->value instanceof Node\Scalar\String_ && str_ends_with($argument->value->value, '.php')) { + return $argument->value->value; + } + } + return null; + } + + /** + * @return list> + */ + private function collectProviderRows(Node\Stmt\ClassMethod $method): array + { + $rows = []; + $this->walkValues($method->stmts ?? [], static function (Node $node) use (&$rows): void { + if (!$node instanceof Expr\Yield_ || !$node->value instanceof Expr\Array_) { + return; + } + $strings = []; + foreach ($node->value->items as $item) { + if ($item?->value instanceof Node\Scalar\String_) { + $strings[] = $item->value->value; + } + } + if ($strings !== []) { + $rows[] = $strings; + } + }); + + if ($rows !== []) { + return $rows; + } + + // Array-returning providers are common for fixture-name lists. + foreach ($method->stmts ?? [] as $statement) { + if (!$statement instanceof Node\Stmt\Return_ || !$statement->expr instanceof Expr\Array_) { + continue; + } + foreach ($statement->expr->items as $outerItem) { + if (!$outerItem?->value instanceof Expr\Array_) { + continue; + } + $strings = []; + foreach ($outerItem->value->items as $item) { + if ($item?->value instanceof Node\Scalar\String_) { + $strings[] = $item->value->value; + } + } + if ($strings !== []) { + $rows[] = $strings; + } + } + } + return $rows; + } + + private function findDataProviderName(Node\Stmt\ClassMethod $method): ?string + { + $comment = $method->getDocComment()?->getText() ?? ''; + if (preg_match('/@dataProvider\s+([A-Za-z_][A-Za-z0-9_]*)/', $comment, $match)) { + return $match[1]; + } + foreach ($method->attrGroups as $group) { + foreach ($group->attrs as $attribute) { + if (strtolower($attribute->name->getLast()) !== 'dataprovider') { + continue; + } + $argument = $attribute->args[0]->value ?? null; + if ($argument instanceof Node\Scalar\String_) { + return $argument->value; + } + } + } + return null; + } + + /** @param mixed $value @return list */ + private function findPhpFixtureStrings(mixed $value): array + { + $fixtures = []; + if (is_string($value)) { + return str_ends_with($value, '.php') ? [$value] : []; + } + if (is_array($value)) { + foreach ($value as $item) { + $fixtures = array_merge($fixtures, $this->findPhpFixtureStrings($item)); + } + return $fixtures; + } + if ($value instanceof Node) { + $this->walkValues($value, static function (Node $node) use (&$fixtures): void { + if ($node instanceof Node\Scalar\String_ && str_ends_with($node->value, '.php')) { + $fixtures[] = $node->value; + } + }); + } + return array_values(array_unique($fixtures)); + } + + /** @param list $row */ + private function findInlinePhpSource(array $row): ?string + { + foreach ($row as $value) { + if (str_contains($value, '|::|=>|\.\.\.|\(void\)|[;{}\[\]]/', $value)) { + return $value; + } + } + return null; + } + + /** @param list $row */ + private function findPhpVersionString(array $row): ?string + { + foreach ($row as $value) { + if (preg_match('/^8\.\d+$/', $value)) { + return $value; + } + } + return null; + } + + private function recordPhpUnitFixture( + string $methodId, + Node\Stmt\ClassMethod $method, + string $fixtureDirectory, + string $fixture, + bool $negative, + ): void { + $fixturePath = rtrim($fixtureDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $fixture; + if (!is_file($fixturePath)) { + $this->unresolvedPhpUnitFixtures[] = ['test' => $methodId, 'fixture' => $fixture]; + return; + } + $fixtureCode = file_get_contents($fixturePath); + if (!is_string($fixtureCode)) { + return; + } + $features = $this->parseSourceFeatures($fixtureCode, $this->relativePath($fixturePath)); + $record = [ + 'id' => $methodId . ' -> ' . $this->relativePath($fixturePath), + 'kind' => 'phpunit_fixture', + 'title' => $method->name->toString(), + 'min_php' => null, + 'max_php' => null, + 'excluded_reason' => null, + 'features' => array_keys($features), + 'negative' => $negative, + ]; + $this->tests[] = $record; + $this->addEvidence($features, $negative ? 'negative_diagnostic' : 'positive_compile', $record); + } + + /** @return array */ + private function parseSourceFeatures(string $code, string $sourceId, bool $expectedInvalid = false): array + { + try { + $stmts = $this->parser->parse($code) ?? []; + } catch (\Throwable $error) { + $target = ['file' => $sourceId, 'message' => $error->getMessage()]; + if ($expectedInvalid) { + $this->expectedParserDiagnostics[] = $target; + } else { + $this->parseErrors[] = $target; + } + $features = []; + $this->detectSourceFeatures($code, $features); + return $features; + } + $features = []; + $this->walkFeatureNodes($stmts, [], $features); + $this->detectSourceFeatures($code, $features); + return $features; + } + + /** + * @param list $ancestors + * @param array $features + */ + private function walkFeatureNodes(mixed $value, array $ancestors, array &$features): void + { + if (is_array($value)) { + foreach ($value as $item) { + $this->walkFeatureNodes($item, $ancestors, $features); + } + return; + } + if (!$value instanceof Node) { + return; + } + + $astFeature = 'ast:' . $value->getType(); + if (isset($this->catalog[$astFeature])) { + $features[$astFeature] = true; + } + $this->detectSemanticNodeFeatures($value, $ancestors, $features); + $nextAncestors = [...$ancestors, $value]; + foreach ($value->getSubNodeNames() as $subNodeName) { + $this->walkFeatureNodes($value->{$subNodeName}, $nextAncestors, $features); + } + } + + /** @param list $ancestors @param array $features */ + private function detectSemanticNodeFeatures(Node $node, array $ancestors, array &$features): void + { + $mark = static function (string $id) use (&$features): void { + $features['semantic:' . $id] = true; + }; + + if ($node instanceof Node\Name\Relative) { + $mark('relative_namespace_name'); + } + if ($node instanceof Node\Arg && $node->name !== null) { + $mark('named_arguments'); + } + if ($node instanceof Node\VariadicPlaceholder) { + $mark('first_class_callable'); + } + if ($node instanceof Node\Param && $node->isPromoted()) { + $mark('constructor_property_promotion'); + if (($node->flags & Modifiers::VISIBILITY_SET_MASK) !== 0) { + $mark('promoted_asymmetric_property'); + $mark('asymmetric_property_visibility'); + } + if (($node->flags & Modifiers::READONLY) !== 0) { + $mark('readonly_property'); + } + if (($node->flags & Modifiers::FINAL) !== 0) { + $mark('final_property'); + } + } + if ($node instanceof Node\Stmt\Property) { + if (($node->flags & Modifiers::VISIBILITY_SET_MASK) !== 0) { + $mark('asymmetric_property_visibility'); + } + if (($node->flags & Modifiers::READONLY) !== 0) { + $mark('readonly_property'); + } + if (($node->flags & Modifiers::FINAL) !== 0) { + $mark('final_property'); + } + if ($node->hooks !== []) { + $mark('property_hooks'); + } + } + if ($node instanceof Node\Stmt\Class_ && $node->isReadonly()) { + $mark('readonly_class'); + } + if ($node instanceof Node\PropertyHook) { + $mark('property_hooks'); + $hookName = strtolower($node->name->toString()); + if ($hookName === 'get') { + $mark('property_hook_get'); + } elseif ($hookName === 'set') { + $mark('property_hook_set'); + } + if ($node->byRef) { + $mark('property_hook_by_reference'); + } + if (($node->flags & Modifiers::FINAL) !== 0) { + $mark('final_property_hook'); + } + } + if ($node instanceof Node\Scalar\MagicConst\Property) { + $mark('property_magic_constant'); + } + if ($node instanceof Expr\Cast\Void_) { + $mark('void_cast'); + } + if ($node instanceof Expr\BinaryOp\Pipe) { + $mark('pipe_operator'); + } + if ($node instanceof Expr\FuncCall + && $node->name instanceof Node\Name + && strtolower($node->name->toString()) === 'clone' + ) { + $mark('clone_with'); + } + if ($node instanceof Node\Stmt\ClassConst && $node->attrGroups !== []) { + $mark('class_constant_attributes'); + } + if ($node instanceof Node\Stmt\Const_ && $node->attrGroups !== []) { + $mark('global_constant_attributes'); + } + if ($node instanceof Node\UnionType && $this->unionContainsIntersection($node)) { + $mark('dnf_types'); + $parent = $ancestors[array_key_last($ancestors)] ?? null; + if ($parent instanceof Node\Param) { + $mark('dnf_parameter'); + if ($this->hasClosureAncestor($ancestors)) { + $mark('dnf_closure_signature'); + } + } elseif ($parent instanceof Node\Stmt\Property) { + $mark('dnf_property'); + } elseif ($parent instanceof FunctionLike) { + $mark('dnf_return'); + if ($parent instanceof Expr\Closure || $parent instanceof Expr\ArrowFunction) { + $mark('dnf_closure_signature'); + } + } + } + if ($node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction) { + foreach (array_reverse($ancestors) as $ancestor) { + if ($ancestor instanceof Node\Const_) { + $mark('constant_closure'); + break; + } + if ($ancestor instanceof Node\Param || $ancestor instanceof Node\PropertyItem) { + $mark('closure_default_value'); + break; + } + } + } + } + + /** @param array $features */ + private function detectSourceFeatures(string $code, array &$features): void + { + $mark = static function (string $id) use (&$features): void { + $features['semantic:' . $id] = true; + }; + if (preg_match('/\b(?:if|elseif|for|foreach|while|switch)\s*\([^;{}]*\)\s*:/s', $code) + || preg_match('/\bend(?:if|for|foreach|while|switch)\s*;/i', $code) + ) { + $mark('alternative_control_syntax'); + } + if (preg_match('/\b0[bB][01](?:_?[01])*\b/', $code)) { + $mark('numeric_literal_binary'); + } + if (preg_match('/\b0[oO][0-7](?:_?[0-7])*\b/', $code)) { + $mark('numeric_literal_explicit_octal'); + } + if (preg_match('/\b(?:\d[\dA-Fa-f]*_[\dA-Fa-f_]*|0[xXbBoO][\dA-Fa-f]+_[\dA-Fa-f_]*)\b/', $code)) { + $mark('numeric_literal_separator'); + } + if (preg_match('/\b(?:exit|die)\s*\(\s*message\s*:/i', $code)) { + $mark('exit_named_argument'); + } + } + + private function unionContainsIntersection(Node\UnionType $type): bool + { + foreach ($type->types as $member) { + if ($member instanceof Node\IntersectionType) { + return true; + } + } + return false; + } + + /** @param list $ancestors */ + private function hasClosureAncestor(array $ancestors): bool + { + foreach ($ancestors as $ancestor) { + if ($ancestor instanceof Expr\Closure || $ancestor instanceof Expr\ArrowFunction) { + return true; + } + } + return false; + } + + /** @param mixed $value @param callable(Node): void $visitor */ + private function walkValues(mixed $value, callable $visitor): void + { + if (is_array($value)) { + foreach ($value as $item) { + $this->walkValues($item, $visitor); + } + return; + } + if (!$value instanceof Node) { + return; + } + $visitor($value); + foreach ($value->getSubNodeNames() as $name) { + $this->walkValues($value->{$name}, $visitor); + } + } + + /** + * @param array $features + * @param array $record + */ + private function addEvidence(array $features, string $axis, array $record): void + { + foreach ($features as $featureId => $_) { + if (isset($this->evidence[$featureId][$axis])) { + $this->evidence[$featureId][$axis][$record['id']] = $record; + } + } + } + + /** @return array */ + private function buildReport( + array $phptPaths, + ?string $phpUnitSourceDirectory, + ?string $phpUnitFixtureDirectory, + ): array { + $features = []; + $matrix = []; + $coverageByVersion = []; + foreach ($this->targetPhpVersions as $version) { + $coverageByVersion[$version] = ['applicable_features' => 0]; + foreach (self::AXES as $axis) { + $coverageByVersion[$version][$axis] = ['covered' => 0, 'denominator' => 0, 'percentage' => 0.0]; + } + } + + foreach ($this->catalog as $id => $definition) { + $feature = $definition; + $feature['evidence'] = []; + foreach (self::AXES as $axis) { + $feature['evidence'][$axis] = array_keys($this->evidence[$id][$axis]); + } + $features[] = $feature; + + foreach ($this->targetPhpVersions as $version) { + if (!$this->featureAppliesToVersion($definition, $version)) { + continue; + } + $coverageByVersion[$version]['applicable_features']++; + $row = [ + 'php_version' => $version, + 'feature_id' => $id, + 'feature' => $definition['label'], + 'category' => $definition['category'], + ]; + foreach (self::AXES as $axis) { + $coverageByVersion[$version][$axis]['denominator']++; + $covered = $this->hasCompatibleEvidence($this->evidence[$id][$axis], $version); + $row[$axis] = $covered; + if ($covered) { + $coverageByVersion[$version][$axis]['covered']++; + } + } + $matrix[] = $row; + } + } + + foreach ($coverageByVersion as &$coverage) { + foreach (self::AXES as $axis) { + $coverage[$axis]['percentage'] = $this->percentage( + $coverage[$axis]['covered'], + $coverage[$axis]['denominator'], + ); + } + } + unset($coverage); + + $astNodes = []; + $astCovered = 0; + $astDenominator = 0; + foreach ($this->catalog as $id => $definition) { + if ($definition['category'] !== 'ast') { + continue; + } + $astDenominator++; + $activeTests = []; + foreach (self::AXES as $axis) { + foreach ($this->evidence[$id][$axis] as $testId => $_) { + $activeTests[$testId] = true; + } + } + if ($activeTests !== []) { + $astCovered++; + } + $astNodes[] = [ + 'node_type' => $definition['node_type'], + 'introduced' => $definition['introduced'], + 'active_test_sources' => count($activeTests), + ]; + } + usort($astNodes, static fn (array $left, array $right): int => $left['node_type'] <=> $right['node_type']); + + $parsedPhptCount = count(array_filter($this->tests, static fn (array $test): bool => $test['kind'] === 'phpt')); + $phpUnitLinks = count(array_filter($this->tests, static fn (array $test): bool => $test['kind'] === 'phpunit_fixture')); + return [ + 'schema_version' => 1, + 'generated_at' => date(DATE_ATOM), + 'scope' => [ + 'phpt_paths' => $phptPaths, + 'phpunit_source_directory' => $phpUnitSourceDirectory, + 'phpunit_fixture_directory' => $phpUnitFixtureDirectory, + 'target_php_versions' => $this->targetPhpVersions, + ], + 'classification_rules' => [ + 'positive_compile' => 'Active, parseable non-diagnostic PHPT sources and positive PHPUnit compile fixtures.', + 'runtime_semantics' => 'Active, non-diagnostic PHPT sources with EXPECT/EXPECTF/EXPECTREGEX.', + 'negative_diagnostic' => 'PHPT diagnostic expectations plus PHPUnit TestError/exec fixture links.', + 'excluded' => 'XFAIL and syntactically unconditional SKIPIF tests do not satisfy evidence axes.', + ], + 'denominators' => [ + 'ast_node_kinds' => [ + 'total' => $astDenominator, + 'definition' => 'Concrete Node kinds shipped by php-parser, excluding Expr_Error parser recovery.', + ], + 'feature_axis' => 'All catalog features whose introduced version is <= the target PHP version.', + ], + 'summary' => [ + 'phpt_files' => $this->discoveredPhptFiles, + 'parsed_phpt_files' => $parsedPhptCount, + 'phpunit_fixture_links' => $phpUnitLinks, + 'parsed_source_records' => count($this->tests), + 'ast_node_coverage' => [ + 'covered' => $astCovered, + 'denominator' => $astDenominator, + 'percentage' => $this->percentage($astCovered, $astDenominator), + ], + 'coverage_by_php_version' => $coverageByVersion, + ], + 'features' => $features, + 'matrix' => $matrix, + 'ast_nodes' => $astNodes, + 'tests' => $this->tests, + 'parse_errors' => $this->parseErrors, + 'expected_parser_diagnostics' => $this->expectedParserDiagnostics, + 'unresolved_phpunit_fixtures' => $this->unresolvedPhpUnitFixtures, + ]; + } + + /** @param array> $evidence */ + private function hasCompatibleEvidence(array $evidence, string $version): bool + { + foreach ($evidence as $record) { + if (($record['min_php'] === null || version_compare($version, $record['min_php'], '>=')) + && ($record['max_php'] === null || version_compare($version, $record['max_php'], '<=')) + ) { + return true; + } + } + return false; + } + + /** @param array $feature */ + private function featureAppliesToVersion(array $feature, string $version): bool + { + return version_compare($version, $feature['introduced'], '>='); + } + + /** @param list $paths @return list */ + private function discoverFiles(array $paths, string $suffix): array + { + $files = []; + foreach ($paths as $path) { + $absolute = $this->absolutePath($path); + if (is_file($absolute)) { + if (str_ends_with($absolute, $suffix)) { + $files[$absolute] = true; + } + continue; + } + if (!is_dir($absolute)) { + throw new \RuntimeException('Test path not found: ' . $path); + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($absolute, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), $suffix)) { + $files[$file->getPathname()] = true; + } + } + } + $files = array_keys($files); + sort($files); + return $files; + } + + private function absolutePath(string $path): string + { + if ($path !== '' && ($path[0] === '/' || preg_match('/^[A-Za-z]:[\\\\\/]/', $path))) { + return $path; + } + return rtrim($this->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path; + } + + private function relativePath(string $path): string + { + $prefix = rtrim($this->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : $path; + } + + private function versionIdToMinor(int $versionId): string + { + return intdiv($versionId, 10000) . '.' . intdiv($versionId % 10000, 100); + } + + private function previousMinor(string $version): string + { + [$major, $minor] = array_map('intval', explode('.', $version, 2)); + return $minor > 0 ? $major . '.' . ($minor - 1) : ($major - 1) . '.99'; + } + + private function maxVersion(?string $left, string $right): string + { + return $left === null || version_compare($right, $left, '>') ? $right : $left; + } + + private function minVersion(?string $left, string $right): string + { + return $left === null || version_compare($right, $left, '<') ? $right : $left; + } + + private function percentage(int $covered, int $denominator): float + { + return $denominator === 0 ? 0.0 : round($covered * 100 / $denominator, 1); + } + + /** @param array{covered: int, denominator: int, percentage: float} $metric */ + private function formatRatio(array $metric): string + { + return sprintf('%d/%d (%.1f%%)', $metric['covered'], $metric['denominator'], $metric['percentage']); + } +} diff --git a/tests/compiler/RUN_TESTS_GUIDE.md b/tests/compiler/RUN_TESTS_GUIDE.md index 7ab30a0c..40cb7bcb 100644 --- a/tests/compiler/RUN_TESTS_GUIDE.md +++ b/tests/compiler/RUN_TESTS_GUIDE.md @@ -192,19 +192,26 @@ cd build && make test-name ## 测试覆盖率检查 -查看当前测试覆盖的 PHP 语法特性: +覆盖清单直接从 PHPT 和相关 PHPUnit fixture 的源码生成: ```bash -# 统计测试文件数量 -ls tests/compiler/*.phpt | wc -l - -# 查看所有测试文件 -ls tests/compiler/*.phpt | sort - -# 查看测试覆盖总结 -cat tests/compiler/README_TEST_COVERAGE.md +# 查看带明确分母的摘要 +php bin/analyze-test-coverage.php + +# 生成 PHP 版本 × 特性 × 三类测试证据的完整矩阵 +php bin/analyze-test-coverage.php \ + --format=markdown \ + --output=build/test-coverage.md + +# CI 使用 JSON,并在解析问题或失效 fixture 引用时失败 +php bin/analyze-test-coverage.php \ + --format=json \ + --output=build/test-coverage.json \ + --strict ``` +报告分别计算 AST 节点、正向编译、运行语义和负向诊断覆盖率,并明确列出每个分母;不会生成含义不清的单一总百分比。分类规则及 JSON 字段说明见 [测试覆盖清单](../../docs/TEST_COVERAGE_ANALYZER.md)。 + ## 常见问题 ### Q: 测试失败,显示 "Not implemented" 错误 diff --git a/tests/compiler/object_property/toarray-dynamic-method.phpt b/tests/compiler/object_property/toarray-dynamic-method.phpt index a7f125c0..735cb923 100644 --- a/tests/compiler/object_property/toarray-dynamic-method.phpt +++ b/tests/compiler/object_property/toarray-dynamic-method.phpt @@ -1,5 +1,5 @@ --TEST-- -Dynamic toArray() dispatch supports real methods and __call +Dynamic toArray() supports scalar conversion, declared methods and object-property fallback --FILE-- $name]; @@ -29,40 +31,57 @@ function callDynamicToArray(mixed $value): array return $value->toArray(); } -function dumpDynamicToArrayError(object $value): void +function callScalarToArray(int $value): array { - try { - callDynamicToArray(eraseToMixed($value)); - } catch (Error $error) { - echo $error->getMessage(), "\n"; - } + return $value->toArray(); } function main(): void { + var_dump(callScalarToArray(42)); + var_dump(callDynamicToArray(null)); + var_dump(callDynamicToArray('value')); + var_dump(callDynamicToArray(['key' => 7])); var_dump(callDynamicToArray(eraseToMixed(new DynamicToArrayValue()))); - dumpDynamicToArrayError(new stdClass()); var_dump((new DynamicToArrayMagicOnly())->toArray()); var_dump(callDynamicToArray(eraseToMixed(new DynamicToArrayMagicOnly()))); $plain = new stdClass(); $plain->value = 7; + var_dump(callDynamicToArray($plain)); var_dump((array) $plain); } ?> --EXPECT-- +array(1) { + [0]=> + int(42) +} +array(0) { +} +array(1) { + [0]=> + string(5) "value" +} +array(1) { + ["key"]=> + int(7) +} array(1) { ["value"]=> int(42) } -Invalid callback stdClass::toArray, class stdClass does not have a method "toArray" array(1) { - ["magic"]=> - string(7) "toArray" + ["value"]=> + int(9) } array(1) { - ["magic"]=> - string(7) "toArray" + ["value"]=> + int(9) +} +array(1) { + ["value"]=> + int(7) } array(1) { ["value"]=>