Skip to content

Add Gitlab / Code Climate Output Format Support #210

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,22 @@ composer fix-cs

<br>

### Controlling Output Format

You may want to use ECS to generate reports for third-party tooling.

We currently provide formatters for:

- `console`: Human-oriented printing à la PHP CS Fixer.
- `json`: A custom JSON blob for arbitrary tooling.
- `checkstyle`: Useful for Github Action Reports.
- `gitlab`: For Gitlab code quality reports or Code Climate tooling.

For information on how each of these behave, refer to their respective
[implementations](src/Console/Output/).

<br>

## FAQ

### How do I clear cache?
Expand Down
1 change: 1 addition & 0 deletions ecs.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
->withPaths([__DIR__ . '/config', __DIR__ . '/src', __DIR__ . '/tests'])
->withRules([LineLengthFixer::class])
->withRootFiles()
->withSkip(['*/Source/*', '*/Fixture/*'])
->withPreparedSets(psr12: true, common: true);
15 changes: 10 additions & 5 deletions src/Configuration/ConfigurationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
namespace Symplify\EasyCodingStandard\Configuration;

use Symfony\Component\Console\Input\InputInterface;
use Symplify\EasyCodingStandard\Console\Output\CheckstyleOutputFormatter;
use Symplify\EasyCodingStandard\Console\Output\JsonOutputFormatter;
use Symplify\EasyCodingStandard\Console\Output\OutputFormatterCollector;
use Symplify\EasyCodingStandard\DependencyInjection\SimpleParameterProvider;
use Symplify\EasyCodingStandard\Exception\Configuration\SourceNotFoundException;
use Symplify\EasyCodingStandard\ValueObject\Configuration;
use Symplify\EasyCodingStandard\ValueObject\Option;

final class ConfigurationFactory
final readonly class ConfigurationFactory
{
public function __construct(
private OutputFormatterCollector $outputFormatterCollector
) {
}

/**
* Needs to run in the start of the life cycle, since the rest of workflow uses it.
*/
Expand Down Expand Up @@ -66,8 +70,9 @@ private function canShowProgressBar(InputInterface $input): bool
}

$outputFormat = $input->getOption(Option::OUTPUT_FORMAT);
$formatsWithoutProgressBar = [CheckstyleOutputFormatter::NAME, JsonOutputFormatter::NAME];
if (in_array($outputFormat, $formatsWithoutProgressBar, true)) {
$formatter = $this->outputFormatterCollector->getByName($outputFormat);

if (! $formatter->hasSupportForProgressBars()) {
return false;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Console/Command/AbstractCheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ protected function configure(): void
null,
InputOption::VALUE_REQUIRED,
'Select output format',
ConsoleOutputFormatter::NAME
ConsoleOutputFormatter::getName()
);

$this->addOption(Option::MEMORY_LIMIT, null, InputOption::VALUE_REQUIRED, 'Memory limit for check');
Expand Down
2 changes: 1 addition & 1 deletion src/Console/Command/ListCheckersCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ protected function configure(): void
null,
InputOption::VALUE_REQUIRED,
'Select output format',
ConsoleOutputFormatter::NAME
ConsoleOutputFormatter::getName()
);

$this->addOption(Option::CONFIG, 'c', InputOption::VALUE_REQUIRED, 'Path to config file');
Expand Down
2 changes: 1 addition & 1 deletion src/Console/EasyCodingStandardConsoleApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ private function shouldPrintMetaInformation(InputInterface $input): bool

$outputFormat = $input->getParameterOption('--' . Option::OUTPUT_FORMAT);

return $outputFormat === ConsoleOutputFormatter::NAME;
return $outputFormat === ConsoleOutputFormatter::getName();
}

private function addExtraOptions(InputDefinition $inputDefinition): void
Expand Down
14 changes: 7 additions & 7 deletions src/Console/Output/CheckstyleOutputFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@
*/
final readonly class CheckstyleOutputFormatter implements OutputFormatterInterface
{
/**
* @var string
*/
public const NAME = 'checkstyle';

public function __construct(
private EasyCodingStandardStyle $easyCodingStandardStyle,
private ExitCodeResolver $exitCodeResolver
Expand All @@ -40,9 +35,14 @@ public function report(ErrorAndDiffResult $errorAndDiffResult, Configuration $co
return $this->exitCodeResolver->resolve($errorAndDiffResult, $configuration);
}

public function getName(): string
public static function getName(): string
{
return 'checkstyle';
}

public static function hasSupportForProgressBars(): bool
{
return self::NAME;
return false;
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/Console/Output/ConsoleOutputFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ public function report(ErrorAndDiffResult $errorAndDiffResult, Configuration $co
return $this->exitCodeResolver->resolve($errorAndDiffResult, $configuration);
}

public function getName(): string
public static function getName(): string
{
return self::NAME;
}

public static function hasSupportForProgressBars(): bool
{
return true;
}

/**
* @param FileDiff[] $fileDiffs
*/
Expand Down
239 changes: 239 additions & 0 deletions src/Console/Output/GitlabOutputFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
<?php

declare(strict_types=1);

namespace Symplify\EasyCodingStandard\Console\Output;

use SebastianBergmann\Diff\Chunk;
use SebastianBergmann\Diff\Line;
use SebastianBergmann\Diff\Parser as DiffParser;
use Symplify\EasyCodingStandard\Console\ExitCode;
use Symplify\EasyCodingStandard\Console\Style\EasyCodingStandardStyle;
use Symplify\EasyCodingStandard\Contract\Console\Output\OutputFormatterInterface;
use Symplify\EasyCodingStandard\SniffRunner\ValueObject\Error\CodingStandardError;
use Symplify\EasyCodingStandard\ValueObject\Configuration;
use Symplify\EasyCodingStandard\ValueObject\Error\ErrorAndDiffResult;
use Symplify\EasyCodingStandard\ValueObject\Error\FileDiff;
use function array_map as map;
use function array_merge as merge;

/**
* Generates a JSON file containing the Gitlab-supported variant of
* "Code Climate" issues for all unresolved errors.
*
* This is compatible with the specs of:
* - Code Climate: ^0.3.1
* - Gitlab: ^16.11 || ^17
*
* Applied diffs are NOT reported, but unapplied diffs ARE. This allows
* our users to choose between a CI/CD workflow where `--fix` is used to
* automatically commit trivial corrections OR one where any errors, even
* resolvable, cause the pipeline to fail.
*
* Unfortunately, because of the way sniffs apply fixes without provenance data
* and with cascading effects, multiple major refactors would be required in
* order to accurately report exactly which fixes apply to exactly which lines.
*
* Finally, all reported errors will be marked as "minor" severity, since
* DevOps downstream can choose if CI/CD ignores and our Sniffers don't
* currently provide their own levels.
*
* As a warning to future maintainers, I believe Gitlab's documentation may be
* slightly wrong. It's not a subset of the Code Climate format as insinuated,
* since it changes some fields from optional to required.
*
* @see https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool
* @see https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#data-types
*
* @phpstan-type GitlabIssue array{
* type: 'issue',
* description: string,
* check_name: string,
* fingerprint: string,
* severity: 'minor',
* categories: array{'Style'},
* remediation_points?: int,
* location: array{
* path: string,
* lines: array{
* begin: int,
* end: int,
* },
* },
* }
*/
final readonly class GitlabOutputFormatter implements OutputFormatterInterface
{
public function __construct(
private readonly EasyCodingStandardStyle $easyCodingStandardStyle,
private readonly ExitCodeResolver $exitCodeResolver,
private readonly DiffParser $diffParser,
) {
}

public static function getName(): string
{
return 'gitlab';
}

public static function hasSupportForProgressBars(): bool
{
return false;
}

/**
* @return ExitCode::*
*/
public function report(ErrorAndDiffResult $errorAndDiffResult, Configuration $configuration): int
{
$output = $this->generateReport($errorAndDiffResult, $configuration);
$this->easyCodingStandardStyle->writeln($output);
return $this->exitCodeResolver->resolve($errorAndDiffResult, $configuration);
}

public function generateReport(ErrorAndDiffResult $errorAndDiffResult, Configuration $configuration): string
{
$reportedQualityIssues = (! $configuration->isFixer() && $configuration->shouldShowDiffs())
? merge(
$this->generateIssuesForErrors($errorAndDiffResult->getErrors()),
$this->generateIssuesForFixes($errorAndDiffResult->getFileDiffs()),
)
: $this->generateIssuesForErrors($errorAndDiffResult->getErrors());

return $this->encode($reportedQualityIssues);
}

/**
* @param CodingStandardError[] $errors
* @return GitlabIssue[]
*/
private function generateIssuesForErrors(array $errors): array
{
return map(
fn (CodingStandardError $codingStandardError): array => [
'type' => 'issue',
'description' => $codingStandardError->getMessage(),
'check_name' => $codingStandardError->getCheckerClass(),
'fingerprint' => $this->generateFingerprint(
$codingStandardError->getCheckerClass(),
$codingStandardError->getMessage(),
$codingStandardError->getRelativeFilePath(),
),
'severity' => 'minor',
'categories' => ['Style'],
'location' => [
'path' => $codingStandardError->getRelativeFilePath(),
'lines' => [
'begin' => $codingStandardError->getLine(),
'end' => $codingStandardError->getLine(),
],
],
],
$errors,
);
}

/**
* Reports each chunk of changes as a separate issue.
*
* @param FileDiff[] $diffs
* @return GitlabIssue[]
*/
private function generateIssuesForFixes(array $diffs): array
{
return merge(
...map(
fn (FileDiff $fileDiff): array => map(
fn (Chunk $chunk): array => $this->generateIssueForChunk($fileDiff, $chunk),
$this->diffParser->parse($fileDiff->getDiff())[0]->chunks(),
),
$diffs,
),
);
}

/**
* @return GitlabIssue
*/
private function generateIssueForChunk(FileDiff $fileDiff, Chunk $chunk): array
{
$checkersAsFqcns = implode(',', $fileDiff->getAppliedCheckers());
$checkersAsClasses = implode(', ', map(
static fn (string $checker): string => preg_replace('/.*\\\/', '', $checker) ?? $checker,
$fileDiff->getAppliedCheckers(),
));

$message = 'Chunk has fixable errors: ' . $checkersAsClasses;
$lineStart = $chunk->start();
$lineEnd = $lineStart + $chunk->startRange() - 1;

return [
'type' => 'issue',
'description' => $message,
'check_name' => $checkersAsFqcns,
'fingerprint' => $this->generateFingerprint(
$checkersAsFqcns,
$message,
$fileDiff->getRelativeFilePath(),
implode(
'\n',
map(static fn (Line $line): string => sprintf(
'%d:%s',
$line->type(),
$line->content()
), $chunk->lines())
),
),
'severity' => 'minor',
'categories' => ['Style'],
'remediation_points' => 50_000,
'location' => [
'path' => $fileDiff->getRelativeFilePath(),
'lines' => [
'begin' => $lineStart,
'end' => $lineEnd,
],
],
];
}

/**
* Generate a fingerprint for a given quality issue. This is used to
* track the presence of an issue between runs, so it should be unique
* and consistent for a given issue.
*
* Subsequently, changing the fingerprint or the data it uses is
* _technically_ a breaking change. Users would see existing issues being
* marked as "new" on the commit proceeding updating ECS.
*
* DO NOT include position information as salting, or every time
* lines are added/removed the lines below it will be reported as
* new errors.
*/
private function generateFingerprint(
string $checker,
string $message,
string $relativeFilePath,
string $salt = '',
): string {
// We implode to add a separator that cannot show up in PHP
// class names or Linux file names and SHOULD never show up in
// messages. This guarantees the same fingerprint won't be generated
// by accident, by the associative property of concatenation. As in:
//
// (ABC + ABC = ABCABC) == (ABCA + BC = ABCABC)
// (ABC + \0 + ABC = ABC\0ABC) != (ABCA + \0 + BC = ABCA\0BC)
return md5(implode("\0", [$checker, $message, $relativeFilePath, $salt]));
}

/**
* @param GitlabIssue[] $lineItems
*/
private function encode(array $lineItems): string
{
return json_encode(
$lineItems,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}
Loading
Loading