respect-validation/src-dev/Commands/LintSpdxCommand.php
Henrique Moody 7db3bea8a6
Enhance LintSpdxCommand with contributor tracking and header normalization
Improves SPDX header linting to ensure consistent license metadata across
the codebase.

Key changes:

- Enforce deterministic tag ordering (License-Identifier, FileCopyrightText,
  FileContributor) to ensure consistency, prevent merge conflicts, and
  simplify code reviews

- Add contributor alias mapping to consolidate contributors with multiple
  emails or name variations (e.g., "nickl-" → "Nick Lombard")

- Add --contributions-strategy option with "blame" (current code authors)
  and "log" (all historical contributors) to support different attribution
  philosophies

- Add optional path argument to lint specific files or directories

- Add --fix option to automatically correct header issues

Assisted-by: Claude Code (claude-opus-4-5-20251101)
2026-02-03 15:23:20 +01:00

186 lines
5.9 KiB
PHP

<?php
/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/
declare(strict_types=1);
namespace Respect\Dev\Commands;
use InvalidArgumentException;
use Respect\Dev\Differ\ConsoleDiffer;
use Respect\Dev\Differ\Item;
use Respect\Dev\Spdx\ContributorExtractor\GitBlameContributorExtractor;
use Respect\Dev\Spdx\ContributorExtractor\GitLogContributorExtractor;
use Respect\Dev\Spdx\ContributorExtractor\HeaderContributorExtractor;
use Respect\Dev\Spdx\ContributorExtractor\NormalizingContributorExtractor;
use Respect\Dev\Spdx\HeaderRebuilder\HtmlHeaderRebuilder;
use Respect\Dev\Spdx\HeaderRebuilder\PhpHeaderRebuilder;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
use function array_keys;
use function array_map;
use function basename;
use function count;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use function is_file;
use function realpath;
use function sprintf;
#[AsCommand(
name: 'lint:spdx',
description: 'Apply SPDX linters to source and documentation files',
)]
final class LintSpdxCommand extends Command
{
public const array EXTENSIONS = [
'php' => '/\/\*+(.*?)\*\//s',
'md' => '/<!--+(.*?)-->/s',
];
public const array SEARCH_DIRS = [
'/src',
'/src-dev',
'/tests',
'/bin',
'/docs',
];
private readonly PhpHeaderRebuilder $phpHeaderRebuilder;
private readonly HtmlHeaderRebuilder $htmlHeaderBuilder;
private readonly GitLogContributorExtractor $gitLogContributorExtractor;
private readonly GitBlameContributorExtractor $gitBlameContributorExtractor;
public function __construct(
private readonly ConsoleDiffer $differ,
) {
parent::__construct();
$this->htmlHeaderBuilder = new HtmlHeaderRebuilder();
$this->phpHeaderRebuilder = new PhpHeaderRebuilder();
$this->gitBlameContributorExtractor = new GitBlameContributorExtractor();
$this->gitLogContributorExtractor = new GitLogContributorExtractor();
}
protected function configure(): void
{
$this->addArgument(
'path',
InputArgument::OPTIONAL,
'File or directory to lint (defaults to the whole codebase).',
);
$this->addOption(
'fix',
null,
InputOption::VALUE_NONE,
'Automatically fix files with issues.',
);
$this->addOption(
'contributions-strategy',
null,
InputOption::VALUE_REQUIRED,
'Strategy to get contributors: "git-blame" (git blame) or "git-log" (git log --follow).',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$root = dirname(__DIR__, 2);
$path = $input->getArgument('path');
$finder = $this->createFinder($root, $path);
$contributionsStrategy = $input->getOption('contributions-strategy');
$updatableFiles = [];
foreach ($finder as $file) {
$content = file_get_contents($file->getRealPath());
$extractor = new NormalizingContributorExtractor(
match ($contributionsStrategy) {
'git-blame' => $this->gitBlameContributorExtractor,
'git-log' => $this->gitLogContributorExtractor,
default => new HeaderContributorExtractor(self::EXTENSIONS[$file->getExtension()]),
},
);
$contributors = $extractor->extract($file->getRealPath());
$rebuilder = match ($file->getExtension()) {
'php' => $this->phpHeaderRebuilder,
default => $this->htmlHeaderBuilder,
};
$fixedContent = $rebuilder->rebuild($content, $contributors);
if ($content === $fixedContent) {
continue;
}
$diff = $this->differ->diff(
new Item($file->getRealPath(), $content),
new Item($file->getRealPath(), $fixedContent),
);
if ($diff === null) {
continue;
}
$output->writeln($diff);
$updatableFiles[$file->getRealPath()] = $fixedContent;
}
if ($updatableFiles === []) {
$output->writeln('<info>No changes needed.</info>');
} else {
$output->writeln(sprintf('<comment>Changes needed in %d files.</comment>', count($updatableFiles)));
}
if ($updatableFiles !== [] && !$input->getOption('fix')) {
return Command::FAILURE;
}
foreach ($updatableFiles as $filepath => $content) {
file_put_contents($filepath, $content);
}
return Command::SUCCESS;
}
private function createFinder(string $root, string|null $path): Finder
{
$finder = new Finder();
if ($path !== null) {
$realPath = realpath($path);
if ($realPath === false) {
throw new InvalidArgumentException(sprintf('Path "%s" does not exist.', $path));
}
if (is_file($realPath)) {
return $finder->in(dirname($realPath))->name(basename($realPath))->files();
}
return $finder->in($realPath)->name('*.php')->name('*.md')->files();
}
$finder = $finder->in(array_map(static fn($dir) => $root . $dir, self::SEARCH_DIRS))->files();
foreach (array_keys(self::EXTENSIONS) as $extension) {
$finder = $finder->name('*.' . $extension);
}
return $finder;
}
}