- 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