whisper-money/app/Console/Commands/CrapCommand.php

397 lines
13 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Support\MethodComplexityVisitor;
use Illuminate\Console\Command;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\NodeVisitor\ParentConnectingVisitor;
use PhpParser\ParserFactory;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Process\Process;
class CrapCommand extends Command
{
protected $signature = 'crap
{--path=app : Directory to analyze}
{--base= : Only report methods whose lines this branch changed since the given git ref}
{--report=build/crap4j.xml : crap4j XML produced by `pest --coverage-crap4j`}
{--no-coverage : Report complexity only, without requiring the coverage report}
{--ignore=.crap-ignore.json : JSON map of exempted methods to the reason they are exempt}
{--threshold=10 : Cyclomatic complexity above which a method fails the check}
{--limit=20 : Methods to list in table output}
{--json : Machine-readable output}';
protected $description = 'Report cyclomatic complexity per method, with CRAP and coverage as context';
public function handle(): int
{
$path = base_path($this->option('path'));
if (! is_dir($path)) {
$this->components->error("Not a directory: {$path}");
return static::INVALID;
}
$threshold = (int) $this->option('threshold');
$touched = $this->option('base') ? $this->touchedLines((string) $this->option('base')) : null;
if ($touched === []) {
return $this->render([], [], [], $threshold, null, 0);
}
$methods = $this->analyze($path, $touched);
$coverage = $this->option('no-coverage') ? null : $this->coverage($methods);
if ($coverage === false) {
return static::INVALID;
}
$ignored = $this->ignored();
$over = array_values(array_filter(
$methods,
fn (array $m) => $m['complexity'] > $threshold && ! isset($ignored[$m['method']]),
));
$exempt = array_values(array_filter(
array_map(
fn (array $m) => $m + ['reason' => $ignored[$m['method']] ?? null],
$methods,
),
fn (array $m) => $m['reason'] !== null,
));
return $this->render($over, $exempt, $methods, $threshold, $coverage, count($methods));
}
/**
* Cyclomatic complexity and line range for every method under $path, keyed by
* method. When $touched is given, only methods overlapping a changed line survive.
*
* @param array<string, list<int>|true>|null $touched
* @return list<array{method: string, file: string, line: int, complexity: int}>
*/
private function analyze(string $path, ?array $touched): array
{
$parser = (new ParserFactory)->createForHostVersion();
$methods = [];
foreach ($this->files($path, $touched) as $file) {
$relative = $this->relative($file->getRealPath());
$ast = $parser->parse((string) file_get_contents($file->getRealPath()));
if ($ast === null) {
continue;
}
foreach ($this->measure($ast) as $name => $measured) {
if ($touched !== null && ! $this->overlaps($touched[$relative] ?? [], $measured['range'])) {
continue;
}
$methods[] = [
'method' => $name,
'file' => $relative,
'line' => $measured['range'][0],
'complexity' => $measured['complexity'],
];
}
}
usort($methods, fn (array $a, array $b) => $b['complexity'] <=> $a['complexity'] ?: strcmp($a['method'], $b['method']));
return $methods;
}
/**
* @param array<string, list<int>|true>|null $touched
* @return iterable<SplFileInfo>
*/
private function files(string $path, ?array $touched): iterable
{
$finder = Finder::create()->files()->in($path)->name('*.php');
if ($touched !== null) {
// ponytail: filter the full scan rather than stat each changed path;
// app/ is ~400 files, so the walk is cheaper than the bookkeeping.
$finder->filter(fn (SplFileInfo $file) => isset($touched[$this->relative($file->getRealPath())]));
}
return $finder;
}
/**
* @param list<Node> $ast
* @return array<string, array{range: array{int, int}, complexity: int}>
*/
private function measure(array $ast): array
{
$visitor = new MethodComplexityVisitor;
$traverser = new NodeTraverser;
$traverser->addVisitor(new NameResolver);
$traverser->addVisitor(new ParentConnectingVisitor);
$traverser->addVisitor($visitor);
$traverser->traverse($ast);
return $visitor->measured;
}
/**
* Lines each file gained or changed since $base, including uncommitted work.
* A value of true means the whole file is new, so every method in it counts.
*
* @return array<string, list<int>|true>
*/
private function touchedLines(string $base): array
{
$touched = [];
// Untracked files never show up in `git diff`, and a new feature is mostly
// new files, so they would otherwise sail through the check untouched.
foreach (preg_split('/\R/', $this->git(['ls-files', '--others', '--exclude-standard', '--', $this->option('path')])) ?: [] as $untracked) {
if (str_ends_with($untracked, '.php')) {
$touched[$untracked] = true;
}
}
$mergeBase = trim($this->git(['merge-base', $base, 'HEAD']));
return $touched + $this->diffLines($mergeBase !== '' ? $mergeBase : $base);
}
/**
* Added and modified line numbers per file, parsed out of a zero-context diff.
*
* @return array<string, list<int>>
*/
private function diffLines(string $ref): array
{
$diff = $this->git(['diff', '--unified=0', '--no-color', $ref, '--', $this->option('path')]);
$lines = [];
$file = null;
foreach (explode("\n", $diff) as $line) {
if (str_starts_with($line, '+++ ')) {
$target = substr($line, 4);
$file = $target === '/dev/null' ? null : preg_replace('#^b/#', '', $target);
continue;
}
if ($file === null || ! str_starts_with($line, '@@') || preg_match('/\+(\d+)(?:,(\d+))?/', $line, $m) !== 1) {
continue;
}
$start = (int) $m[1];
for ($i = 0; $i < (isset($m[2]) ? (int) $m[2] : 1); $i++) {
$lines[$file][] = $start + $i;
}
}
return $lines;
}
/**
* @param list<int>|true $lines
* @param array{int, int} $range
*/
private function overlaps(array|bool $lines, array $range): bool
{
if ($lines === true) {
return true;
}
foreach ($lines as $line) {
if ($line >= $range[0] && $line <= $range[1]) {
return true;
}
}
return false;
}
/**
* Decorate $methods in place with crap and coverage from the crap4j report.
* Returns report metadata, or false when the report is unusable.
*
* @param list<array{method: string, file: string, line: int, complexity: int}> $methods
* @return array{source: string, generated_at: string, stale: bool}|false
*/
private function coverage(array &$methods): array|false
{
$report = base_path($this->option('report'));
if (! is_file($report)) {
$this->components->error(sprintf(
"No coverage report at %s.\n Generate it with: php -d pcov.directory=%s ./vendor/bin/pest --exclude-testsuite=Browser,Performance --coverage-crap4j=%s\n Or skip coverage with --no-coverage to report complexity only.",
$this->option('report'),
$this->option('path'),
$this->option('report'),
));
return false;
}
$xml = simplexml_load_file($report);
if ($xml === false) {
$this->components->error("Could not parse {$report}");
return false;
}
$byMethod = [];
foreach ($xml->methods->method as $entry) {
$byMethod[(string) $entry->className.'::'.(string) $entry->methodName] = [
'crap' => round((float) $entry->crap, 1),
'coverage' => round((float) $entry->coverage, 1),
];
}
$newest = 0;
foreach ($methods as $i => $method) {
$methods[$i] += $byMethod[$method['method']] ?? ['crap' => null, 'coverage' => null];
$newest = max($newest, (int) @filemtime(base_path($method['file'])));
}
return [
'source' => $this->option('report'),
'generated_at' => date('c', (int) filemtime($report)),
'stale' => $newest > filemtime($report),
];
}
/**
* @return array<string, string>
*/
private function ignored(): array
{
$file = base_path($this->option('ignore'));
if (! is_file($file)) {
return [];
}
$decoded = json_decode((string) file_get_contents($file), true);
return is_array($decoded) ? array_filter($decoded, 'is_string') : [];
}
/**
* @param list<array<string, mixed>> $over
* @param list<array<string, mixed>> $exempt
* @param list<array<string, mixed>> $methods
* @param array{source: string, generated_at: string, stale: bool}|null $coverage
*/
private function render(array $over, array $exempt, array $methods, int $threshold, ?array $coverage, int $analyzed): int
{
$stale = array_values(array_filter($exempt, fn (array $m) => $m['complexity'] <= $threshold));
$payload = [
'scope' => 'php',
'included' => [$this->option('path').'/'],
'excluded' => ['resources/js/ (no complexity pipeline)'],
'mode' => $this->option('base') ? 'changed' : 'project',
'base' => $this->option('base') ?: null,
'threshold' => $threshold,
'coverage' => $coverage,
'methods_analyzed' => $analyzed,
'over_threshold' => $over,
'exempt' => $exempt,
'stale_exemptions' => $stale,
'verdict' => $over === [] ? 'pass' : 'fail',
];
if ($this->option('json')) {
$this->line((string) json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->renderText($payload);
}
return $over === [] ? static::SUCCESS : static::FAILURE;
}
/**
* @param array<string, mixed> $payload
*/
private function renderText(array $payload): void
{
if ($payload['coverage'] !== null && $payload['coverage']['stale']) {
$this->components->warn(sprintf(
'Coverage report is older than the code it describes; crap and coverage columns may be wrong (%s).',
$payload['coverage']['source'],
));
}
if ($payload['over_threshold'] === []) {
$this->components->info(sprintf(
'%d method(s) analyzed, none above complexity %d.',
$payload['methods_analyzed'],
$payload['threshold'],
));
} else {
$this->renderViolations($payload['over_threshold'], $payload['threshold']);
}
if ($payload['stale_exemptions'] !== []) {
$this->components->warn(sprintf(
'These %s entries are no longer needed: %s',
$this->option('ignore'),
implode(', ', array_column($payload['stale_exemptions'], 'method')),
));
}
}
/**
* @param list<array<string, mixed>> $over
*/
private function renderViolations(array $over, int $threshold): void
{
$limit = (int) $this->option('limit');
$this->components->error(sprintf('%d method(s) above complexity %d:', count($over), $threshold));
$this->table(
['Cplx', 'CRAP', 'Cov%', 'Method', 'Location'],
array_map(fn (array $m) => [
$m['complexity'],
$m['crap'] ?? '-',
$m['coverage'] ?? '-',
$m['method'],
$m['file'].':'.$m['line'],
], array_slice($over, 0, $limit)),
);
if (count($over) > $limit) {
$this->line(sprintf(' ... and %d more.', count($over) - $limit));
}
}
/**
* @param list<string> $args
*/
private function git(array $args): string
{
$process = new Process(['git', ...$args], base_path());
$process->run();
return $process->isSuccessful() ? $process->getOutput() : '';
}
private function relative(string $absolute): string
{
return ltrim(str_replace(base_path(), '', $absolute), DIRECTORY_SEPARATOR);
}
}