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
master
韩天峰 17 hours ago
parent 178636a577
commit 19a570ff3f
  1. 146
      bin/analyze-test-coverage.php
  2. 48
      docs/TEST_COVERAGE_ANALYZER.md
  3. 116
      package-lock.json
  4. 8
      phpunit/src/ClassTest.php
  5. 146
      phpunit/src/Testing/TestCoverageAnalyzerTest.php
  6. 19
      src/Parser/MethodCallTrait.php
  7. 2
      src/Parser/UniversalMethodCall.php
  8. 1220
      src/Testing/TestCoverageAnalyzer.php
  9. 25
      tests/compiler/RUN_TESTS_GUIDE.md
  10. 45
      tests/compiler/object_property/toarray-dynamic-method.phpt

@ -0,0 +1,146 @@
#!/usr/bin/env php
<?php
/**
* This file is part of Swoole-Compiler(AOT).
*
* @link https://www.swoole.com/
* @contact service@swoole.com
*/
declare(strict_types=1);
use TypePhp\Testing\TestCoverageAnalyzer;
require __DIR__ . '/bootstrap.php';
$format = 'summary';
$output = null;
$includePhpUnit = true;
$strict = false;
$phpVersions = ['8.4', '8.5'];
$paths = [];
foreach (array_slice($argv, 1) as $argument) {
if ($argument === '--help' || $argument === '-h') {
printUsage($argv[0]);
exit(0);
}
if ($argument === '--no-phpunit') {
$includePhpUnit = false;
continue;
}
if ($argument === '--strict') {
$strict = true;
continue;
}
if (str_starts_with($argument, '--format=')) {
$format = substr($argument, strlen('--format='));
continue;
}
if (str_starts_with($argument, '--output=')) {
$output = substr($argument, strlen('--output='));
continue;
}
if (str_starts_with($argument, '--php-versions=')) {
$phpVersions = array_values(array_filter(array_map('trim', explode(',', substr($argument, strlen('--php-versions='))))));
continue;
}
if (str_starts_with($argument, '-')) {
fwrite(STDERR, 'Unknown option: ' . $argument . PHP_EOL);
exit(2);
}
$paths[] = $argument;
}
if (!in_array($format, ['summary', 'json', 'markdown'], true)) {
fwrite(STDERR, 'Invalid format. Expected summary, json or markdown.' . PHP_EOL);
exit(2);
}
if ($phpVersions === []) {
fwrite(STDERR, 'At least one target PHP version is required.' . PHP_EOL);
exit(2);
}
foreach ($phpVersions as $version) {
if (!preg_match('/^\d+\.\d+$/', $version)) {
fwrite(STDERR, 'Invalid PHP version: ' . $version . PHP_EOL);
exit(2);
}
}
if ($paths === []) {
$paths = ['tests/compiler'];
}
try {
$analyzer = new TestCoverageAnalyzer(ROOT_PATH, $phpVersions);
$report = $analyzer->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 <<<USAGE
Usage:
php {$script} [options] [PHPT path ...]
Options:
--format=summary|json|markdown Output format (default: summary)
--output=<file> 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;
}

@ -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: ...)`)由分析器中的显式特性目录补充。新增语言特性时应同时登记其引入版本和检测规则,以维持版本矩阵的明确分母。

116
package-lock.json generated

@ -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"
}
}
}
}

@ -20,16 +20,12 @@ class ClassTest extends \BaseTest
$this->compile('zend-class-to-array-return-type.php'); $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'); $this->compile('zend-class-to-array-missing.php');
} }
public function testKnownZendClassMayResolveToArrayThroughMagicCall(): void public function testKnownZendClassWithMagicCallStillUsesArrayConversion(): void
{ {
$this->compile('zend-class-to-array-magic.php'); $this->compile('zend-class-to-array-magic.php');
} }

@ -0,0 +1,146 @@
<?php
/**
* This file is part of Swoole-Compiler(AOT).
*
* @link https://www.swoole.com/
* @contact service@swoole.com
*/
declare(strict_types=1);
namespace TypePhp\Tests\Testing;
use PHPUnit\Framework\TestCase;
use TypePhp\Testing\TestCoverageAnalyzer;
/**
* @internal
* @coversNothing
*/
final class TestCoverageAnalyzerTest extends TestCase
{
private string $testRoot;
protected function setUp(): void
{
$this->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--
<?php
function main(): void
{
(void) strlen('value');
}
--EXPECT--
PHPT);
file_put_contents($this->testRoot . '/phpt/negative-named-exit.phpt', <<<'PHPT'
--TEST--
PHP 8.4 invalid named exit argument fails cleanly
--FILE--
<?php
function main(): void
{
exit(message: 'failure');
}
--EXPECTF--
Fatal error: unsupported test diagnostic in %s on line %d
PHPT);
file_put_contents($this->testRoot . '/phpunit-src/CoverageFixtureTest.php', <<<'PHP'
<?php
final class CoverageFixtureTest extends \PHPUnit\Framework\TestCase
{
/** @dataProvider invalidProvider */
public function testRejectsUnsupportedFeature(string $source, string $phpVersion): void
{
$this->expectException(\RuntimeException::class);
}
public static function invalidProvider(): iterable
{
yield [
'<?php class Example { public string $value { &get => $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<string, mixed> $report @return array<string, mixed> */
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<string, mixed> $report @return array<string, mixed>|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;
}
}

@ -549,25 +549,12 @@ trait MethodCallTrait
if ($receiverClass === '' && !$this->isVarExpr($expr->var)) { if ($receiverClass === '' && !$this->isVarExpr($expr->var)) {
$receiverClass = $this->detectClassOfExpr($expr->var); $receiverClass = $this->detectClassOfExpr($expr->var);
} }
// A statically known object method preserves keyword priority // A declared conversion method is called directly. Otherwise
// while avoiding the generic PHPX conversion helper. // php::toArray() applies the PHP-compatible object-property
// fallback (and invokes a real toArray() method when present).
$useDeclaredToArray = $methodName === 'toArray' $useDeclaredToArray = $methodName === 'toArray'
&& $receiverClass !== '' && $receiverClass !== ''
&& $this->objectTypeDeclaresMethod($receiverClass, $methodName); && $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) { if (!$useDeclaredToArray) {
return $this->genToConvertCall($object, $methodName, $receiverType); return $this->genToConvertCall($object, $methodName, $receiverType);
} }

@ -448,7 +448,7 @@ trait UniversalMethodCall
'toFloat' => 'php::toFloat(' . $receiver . ')', 'toFloat' => 'php::toFloat(' . $receiver . ')',
'toString' => 'php::toString(' . $receiver . ')', 'toString' => 'php::toString(' . $receiver . ')',
'toBool' => 'php::toBool(' . $receiver . ')', 'toBool' => 'php::toBool(' . $receiver . ')',
'toArray' => 'php::callToArray(' . $receiver . ')', 'toArray' => 'php::toArray(' . $receiver . ')',
'toStream' => 'php::toStream(' . $receiver . ')', 'toStream' => 'php::toStream(' . $receiver . ')',
'toBigInt' => 'php::BigInt::newInstance(' . $receiver . ')', 'toBigInt' => 'php::BigInt::newInstance(' . $receiver . ')',
'toBigFloat' => 'php::BigFloat::newInstance(' . $receiver . ')', 'toBigFloat' => 'php::BigFloat::newInstance(' . $receiver . ')',

File diff suppressed because it is too large Load Diff

@ -192,19 +192,26 @@ cd build && make test-name
## 测试覆盖率检查 ## 测试覆盖率检查
查看当前测试覆盖的 PHP 语法特性 覆盖清单直接从 PHPT 和相关 PHPUnit fixture 的源码生成
```bash ```bash
# 统计测试文件数量 # 查看带明确分母的摘要
ls tests/compiler/*.phpt | wc -l php bin/analyze-test-coverage.php
# 查看所有测试文件 # 生成 PHP 版本 × 特性 × 三类测试证据的完整矩阵
ls tests/compiler/*.phpt | sort php bin/analyze-test-coverage.php \
--format=markdown \
# 查看测试覆盖总结 --output=build/test-coverage.md
cat tests/compiler/README_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" 错误 ### Q: 测试失败,显示 "Not implemented" 错误

@ -1,5 +1,5 @@
--TEST-- --TEST--
Dynamic toArray() dispatch supports real methods and __call Dynamic toArray() supports scalar conversion, declared methods and object-property fallback
--FILE-- --FILE--
<?php <?php
@ -13,6 +13,8 @@ class DynamicToArrayValue
class DynamicToArrayMagicOnly class DynamicToArrayMagicOnly
{ {
public int $value = 9;
public function __call(string $name, array $arguments): array public function __call(string $name, array $arguments): array
{ {
return ['magic' => $name]; return ['magic' => $name];
@ -29,40 +31,57 @@ function callDynamicToArray(mixed $value): array
return $value->toArray(); return $value->toArray();
} }
function dumpDynamicToArrayError(object $value): void function callScalarToArray(int $value): array
{ {
try { return $value->toArray();
callDynamicToArray(eraseToMixed($value));
} catch (Error $error) {
echo $error->getMessage(), "\n";
}
} }
function main(): void 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()))); var_dump(callDynamicToArray(eraseToMixed(new DynamicToArrayValue())));
dumpDynamicToArrayError(new stdClass());
var_dump((new DynamicToArrayMagicOnly())->toArray()); var_dump((new DynamicToArrayMagicOnly())->toArray());
var_dump(callDynamicToArray(eraseToMixed(new DynamicToArrayMagicOnly()))); var_dump(callDynamicToArray(eraseToMixed(new DynamicToArrayMagicOnly())));
$plain = new stdClass(); $plain = new stdClass();
$plain->value = 7; $plain->value = 7;
var_dump(callDynamicToArray($plain));
var_dump((array) $plain); var_dump((array) $plain);
} }
?> ?>
--EXPECT-- --EXPECT--
array(1) {
[0]=>
int(42)
}
array(0) {
}
array(1) {
[0]=>
string(5) "value"
}
array(1) {
["key"]=>
int(7)
}
array(1) { array(1) {
["value"]=> ["value"]=>
int(42) int(42)
} }
Invalid callback stdClass::toArray, class stdClass does not have a method "toArray"
array(1) { array(1) {
["magic"]=> ["value"]=>
string(7) "toArray" int(9)
} }
array(1) { array(1) {
["magic"]=> ["value"]=>
string(7) "toArray" int(9)
}
array(1) {
["value"]=>
int(7)
} }
array(1) { array(1) {
["value"]=> ["value"]=>

Loading…
Cancel
Save