- 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 processingmaster
parent
178636a577
commit
19a570ff3f
10 changed files with 1730 additions and 45 deletions
@ -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: ...)`)由分析器中的显式特性目录补充。新增语言特性时应同时登记其引入版本和检测规则,以维持版本矩阵的明确分母。 |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue