├── .gitignore
├── phpstan.neon
├── LintGuard
├── ShellException.php
├── NoChangesException.php
├── CacheObject.php
├── CacheInterface.php
├── Reporter.php
├── LinterOptions.php
├── ShellOperator.php
├── PhpcsMessages.php
├── DiffLineType.php
├── functions.php
├── CacheEntry.php
├── DiffLine.php
├── UnixShell.php
├── LintMessage.php
├── Config.php
├── CliOptions.php
├── PhpcsMessagesHelpers.php
├── LintMessages.php
├── FileCache.php
├── JsonReporter.php
├── FullReporter.php
├── SvnWorkflow.php
├── XmlReporter.php
├── DiffLineMap.php
├── CacheManager.php
├── GitWorkflow.php
└── Cli.php
├── phpcs.xml
├── phpunit.xml.dist
├── tests
├── helpers
│ ├── TestXmlReporter.php
│ ├── Functions.php
│ ├── helpers.php
│ ├── PhpcsFixture.php
│ ├── TestCache.php
│ ├── GitFixture.php
│ ├── SvnFixture.php
│ └── TestShell.php
├── fixtures
│ ├── review-stuck-orders.diff
│ ├── old-phpcs-output.json
│ └── new-phpcs-output.json
├── CliTest.php
├── PhpcsMessagesTest.php
├── DiffLineMapTest.php
├── JsonReporterTest.php
├── XmlReporterTest.php
├── FullReporterTest.php
├── PhpcsChangedTest.php
├── GitWorkflowTest.php
└── SvnWorkflowTest.php
├── .circleci
└── config.yml
├── LICENSE
├── composer-php-8.json
├── composer.json
├── index.php
├── bin
└── lintguard
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor
2 | composer.lock
3 | *.cache
4 | *-cache
5 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: max
3 | inferPrivatePropertyTypeFromConstructor: true
4 | checkMissingIterableValueType: false
5 |
--------------------------------------------------------------------------------
/LintGuard/ShellException.php:
--------------------------------------------------------------------------------
1 |
2 |
3 | Run various code linters but only report messages caused by recent changes.
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/LintGuard/CacheObject.php:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | tests
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/LintGuard/CacheInterface.php:
--------------------------------------------------------------------------------
1 | command = $command;
19 | $this->args = $args;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tests/helpers/helpers.php:
--------------------------------------------------------------------------------
1 | 'ERROR',
12 | 'severity' => 5,
13 | 'fixable' => false,
14 | 'column' => 5,
15 | 'source' => 'Variables.Defined.RequiredDefined.Unused',
16 | 'line' => $lineNumber,
17 | 'message' => $message,
18 | ];
19 | }, $lineNumbers);
20 | return PhpcsMessages::fromArrays($arrays, $filename);
21 | }
22 |
23 | public function getEmptyResults(): PhpcsMessages {
24 | return PhpcsMessages::fromPhpcsJson('{"totals":{"errors":0,"warnings":0,"fixable":0},"files":{}}');
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/LintGuard/DiffLineType.php:
--------------------------------------------------------------------------------
1 | type = $type;
11 | }
12 |
13 | public static function makeAdd(): self {
14 | return new self('add');
15 | }
16 |
17 | public static function makeRemove(): self {
18 | return new self('remove');
19 | }
20 |
21 | public static function makeContext(): self {
22 | return new self('context');
23 | }
24 |
25 | public function isAdd(): bool {
26 | return $this->type === 'add';
27 | }
28 |
29 | public function isRemove(): bool {
30 | return $this->type === 'remove';
31 | }
32 |
33 | public function isContext(): bool {
34 | return $this->type === 'context';
35 | }
36 |
37 | public function __toString(): string {
38 | return $this->type;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/LintGuard/functions.php:
--------------------------------------------------------------------------------
1 |
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/composer-php-8.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sirbrillig/lintguard",
3 | "description": "Run various code linters but only report messages caused by recent changes.",
4 | "type": "library",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Payton Swick",
9 | "email": "payton@foolord.com"
10 | }
11 | ],
12 | "minimum-stability": "dev",
13 | "prefer-stable": true,
14 | "scripts": {
15 | "test": "./vendor/bin/phpunit --color=always --verbose"
16 | },
17 | "bin": [
18 | "bin/lintguard"
19 | ],
20 | "autoload": {
21 | "psr-4": {
22 | "LintGuard\\": "LintGuard/"
23 | },
24 | "files": [
25 | "LintGuard/Cli.php",
26 | "LintGuard/SvnWorkflow.php",
27 | "LintGuard/GitWorkflow.php",
28 | "LintGuard/functions.php"
29 | ]
30 | },
31 | "require": {
32 | "php": "^7.1 || ^8.0"
33 | },
34 | "require-dev": {
35 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1",
36 | "phpunit/phpunit": "^6.4 || ^9.5",
37 | "squizlabs/php_codesniffer": "^3.2.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/LintGuard/CacheEntry.php:
--------------------------------------------------------------------------------
1 | $this->path,
35 | 'hash' => $this->hash,
36 | 'type' => $this->type,
37 | 'phpcsStandard' => $this->phpcsStandard,
38 | 'data' => $this->data,
39 | ];
40 | }
41 |
42 | public static function fromJson(array $deserializedJson): self {
43 | $entry = new CacheEntry();
44 | $entry->path = $deserializedJson['path'];
45 | $entry->hash = $deserializedJson['hash'];
46 | $entry->type = $deserializedJson['type'];
47 | $entry->phpcsStandard = $deserializedJson['phpcsStandard'];
48 | $entry->data = $deserializedJson['data'];
49 | return $entry;
50 | }
51 |
52 | public function __toString(): string {
53 | return "Cache entry for file '{$this->path}', type '{$this->type}', hash '{$this->hash}', standard '{$this->phpcsStandard}': {$this->data}";
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/LintGuard/DiffLine.php:
--------------------------------------------------------------------------------
1 | type = $type;
31 | $this->line = $line;
32 | if (! $type->isAdd()) {
33 | $this->oldLine = $oldLine;
34 | }
35 | if (! $type->isRemove()) {
36 | $this->newLine = $newLine;
37 | }
38 | }
39 |
40 | public function getOldLineNumber(): ?int {
41 | return $this->oldLine;
42 | }
43 |
44 | public function getNewLineNumber(): ?int {
45 | return $this->newLine;
46 | }
47 |
48 | public function getType(): DiffLineType {
49 | return $this->type;
50 | }
51 |
52 | public function getLine(): string {
53 | return $this->line;
54 | }
55 |
56 | public function __toString(): string {
57 | $oldLine = $this->oldLine ?? 'none';
58 | $newLine = $this->newLine ?? 'none';
59 | $type = (string)$this->type;
60 | return "({$type}) {$oldLine} => {$newLine}: {$this->line}";
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/LintGuard/UnixShell.php:
--------------------------------------------------------------------------------
1 | /dev/null 2>&1", escapeshellarg($command)), $ignore, $returnVal);
15 | if ($returnVal != 0) {
16 | throw new \Exception("Cannot find executable for {$name}, currently set to '{$command}'.");
17 | }
18 | }
19 |
20 | public function executeCommand(string $command, array &$output = null, int &$return_val = null): string {
21 | exec($command, $output, $return_val) ?? '';
22 | return join(PHP_EOL, $output) . PHP_EOL;
23 | }
24 |
25 | public function isReadable(string $fileName): bool {
26 | return is_readable($fileName);
27 | }
28 |
29 | public function getFileHash(string $fileName): string {
30 | $result = md5_file($fileName);
31 | if ($result === false) {
32 | throw new \Exception("Cannot get hash for file '{$fileName}'.");
33 | }
34 | return $result;
35 | }
36 |
37 | public function exitWithCode(int $code): void {
38 | exit($code);
39 | }
40 |
41 | public function printError(string $output): void {
42 | printError($output);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/LintGuard/LintMessage.php:
--------------------------------------------------------------------------------
1 | line = $line;
14 | $this->file = $file;
15 | $this->type = $type;
16 | $this->otherProperties = $otherProperties;
17 | }
18 |
19 | public function getLineNumber(): int {
20 | return $this->line;
21 | }
22 |
23 | public function getFile(): ?string {
24 | return $this->file;
25 | }
26 |
27 | public function setFile(string $file): void {
28 | $this->file = $file;
29 | }
30 |
31 | public function getType(): string {
32 | return $this->type;
33 | }
34 |
35 | public function getMessage(): string {
36 | return $this->otherProperties['message'] ?? '';
37 | }
38 |
39 | public function getSource(): string {
40 | return $this->otherProperties['source'] ?? '';
41 | }
42 |
43 | public function getColumn(): int {
44 | return $this->otherProperties['column'] ?? 0;
45 | }
46 |
47 | public function getSeverity(): int {
48 | return $this->otherProperties['severity'] ?? 5;
49 | }
50 |
51 | /**
52 | * @return string|int|bool|float|null
53 | */
54 | public function getProperty( string $key ) {
55 | return $this->otherProperties[$key];
56 | }
57 |
58 | public function getOtherProperties(): array {
59 | return $this->otherProperties;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/LintGuard/Config.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | public $linters = [];
23 |
24 | public function __construct() {
25 | $this->linters = [
26 | 'phpcs' => new LinterOptions('phpcs', ['--report=json']),
27 | ];
28 | }
29 |
30 | public static function fromJson(string $json): self {
31 | $raw = json_decode($json, null, 512, JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR );
32 | $config = new self();
33 | if (! empty($raw['version-control']['svn'])) {
34 | $config->svn = $raw['version-control']['svn'];
35 | }
36 | if (! empty($raw['version-control']['git'])) {
37 | $config->git = $raw['version-control']['git'];
38 | }
39 | if (! empty($raw['linter-options']) && is_array($raw['linter-options'])) {
40 | foreach ($raw['linter-options'] as $key => $linter) {
41 | if (! isset($config->linters[$key])) {
42 | $config->linters[$key] = new LinterOptions();
43 | }
44 | if (! empty($raw['linter-options'][$key]['command'])) {
45 | $config->linters[$key]->command = $raw['linter-options'][$key]['command'];
46 | }
47 | if (! empty($raw['linter-options'][$key]['args'])) {
48 | $config->linters[$key]->command = $raw['linter-options'][$key]['args'];
49 | }
50 | }
51 | }
52 | return $config;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sirbrillig/lintguard",
3 | "description": "Run various code linters but only report messages caused by recent changes.",
4 | "type": "library",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Payton Swick",
9 | "email": "payton@foolord.com"
10 | }
11 | ],
12 | "minimum-stability": "dev",
13 | "prefer-stable": true,
14 | "scripts": {
15 | "precommit": "composer test && composer lint && composer phpstan",
16 | "test": "./vendor/bin/phpunit --color=always --verbose",
17 | "lint": "./vendor/bin/phpcs -s LintGuard bin tests index.php",
18 | "phpstan": "./vendor/bin/phpstan analyze LintGuard/"
19 | },
20 | "bin": [
21 | "bin/lintguard"
22 | ],
23 | "autoload": {
24 | "psr-4": {
25 | "LintGuard\\": "LintGuard/"
26 | },
27 | "files": [
28 | "LintGuard/Cli.php",
29 | "LintGuard/SvnWorkflow.php",
30 | "LintGuard/GitWorkflow.php",
31 | "LintGuard/functions.php"
32 | ]
33 | },
34 | "require": {
35 | "php": "^7.1 || ^8.0"
36 | },
37 | "require-dev": {
38 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1",
39 | "phpunit/phpunit": "^6.4 || ^9.5",
40 | "squizlabs/php_codesniffer": "^3.2.1",
41 | "sirbrillig/phpcs-variable-analysis": "^2.1.3",
42 | "sirbrillig/phpcs-import-detection": "^1.1.1",
43 | "phpstan/phpstan": "^0.12.33"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/index.php:
--------------------------------------------------------------------------------
1 | disabled) {
34 | return new CacheObject();
35 | }
36 | $this->didSave = false;
37 | $cacheObject = new CacheObject();
38 | $cacheObject->cacheVersion = $this->cacheVersion ?? getVersion();
39 | foreach(array_values($this->savedFileData) as $entry) {
40 | $cacheObject->entries[] = CacheEntry::fromJson($entry);
41 | }
42 | return $cacheObject;
43 | }
44 |
45 | public function save(CacheObject $cacheObject): void {
46 | if ($this->disabled) {
47 | return;
48 | }
49 | $this->didSave = true;
50 | $this->setCacheVersion($cacheObject->cacheVersion);
51 | $this->savedFileData = [];
52 | foreach($cacheObject->entries as $entry) {
53 | $this->setEntry($entry->path, $entry->type, $entry->hash, $entry->phpcsStandard, $entry->data);
54 | }
55 | }
56 |
57 | public function setEntry(string $path, string $type, string $hash, string $phpcsStandard, string $data): void {
58 | $this->savedFileData[] = [
59 | 'path' => $path,
60 | 'hash' => $hash,
61 | 'data' => $data,
62 | 'type' => $type,
63 | 'phpcsStandard' => $phpcsStandard,
64 | ];
65 | }
66 |
67 | public function setCacheVersion(string $cacheVersion): void {
68 | $this->cacheVersion = $cacheVersion;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/tests/CliTest.php:
--------------------------------------------------------------------------------
1 | isFile = $isFile;
13 | }
14 |
15 | public function isFile(): bool {
16 | return $this->isFile;
17 | }
18 | }
19 |
20 | final class CliTest extends TestCase {
21 | public function filesProvider() {
22 | return [
23 | 'PHP File' => ['example.php', true, true],
24 | 'Dir' => ['example', false, false],
25 | 'JS File' => ['example.js', true, true],
26 | 'INC file' => ['example.inc', true, true],
27 | 'Dot File' => ['.example', true, false],
28 | 'Dot INC dot PHP' => ['example.inc.php', true, true],
29 | ];
30 | }
31 |
32 | /**
33 | * @dataProvider filesProvider
34 | */
35 | public function testFileHasValidExtension( $fileName, $isFile, $hasValidExtension ) {
36 |
37 | $file = new MockSplFileInfo($fileName);
38 | $file->setIsFile($isFile);
39 | $this->assertEquals(fileHasValidExtension($file), $hasValidExtension);
40 | }
41 |
42 | public function ignoreProvider(): array {
43 | return [
44 | ['bin/*', 'bin', true],
45 | ['*.php', 'bin/foobar.php', true],
46 | ['.php', 'bin/foobar.php', true],
47 | ['foobar.php', 'bin/foobar.php', true],
48 | ['.inc', 'bin/foobar.php', false],
49 | ['bar.php', 'foo.php', false],
50 | ['bar.phpfoo.php', 'bin/foobar.php', false],
51 | ['foobar.php,bin/', 'bin/foo.php', true],
52 | ];
53 | }
54 |
55 | /**
56 | * @dataProvider ignoreProvider
57 | */
58 | public function testShouldIgnorePath(string $pattern, string $path, bool $expected): void {
59 | $this->assertEquals($expected, shouldIgnorePath($path, $pattern));
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/LintGuard/CliOptions.php:
--------------------------------------------------------------------------------
1 | configPath = $options['config'];
65 |
66 | if (isset($options['linter'])) {
67 | $cliOptions->linter = $options['linter'];
68 | }
69 | if (isset($options['svn'])) {
70 | $cliOptions->svnMode = true;
71 | }
72 | if (isset($options['git-unstaged'])) {
73 | $cliOptions->gitUnstaged = true;
74 | }
75 | if (isset($options['git-staged'])) {
76 | $cliOptions->gitStaged = true;
77 | }
78 | if (isset($options['git-base'])) {
79 | $cliOptions->gitBase = $options['git-base'];
80 | }
81 | if (isset($options['report'])) {
82 | $cliOptions->reporter = $options['report'];
83 | }
84 | if (isset($options['debug'])) {
85 | $cliOptions->reporter = true;
86 | }
87 | if (isset($options['clearCache'])) {
88 | $cliOptions->clearCache = true;
89 | }
90 | if (isset($options['cache'])) {
91 | $cliOptions->useCache = true;
92 | }
93 | if (isset($options['no-cache'])) {
94 | $cliOptions->useCache = false;
95 | }
96 | return $cliOptions;
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/LintGuard/PhpcsMessagesHelpers.php:
--------------------------------------------------------------------------------
1 | getFormattedMessages($messages, []);
41 | }
42 |
43 | public static function fromArrays(array $messages, string $fileName = null): PhpcsMessages {
44 | return new PhpcsMessages(array_map(function(array $messageArray) use ($fileName) {
45 | return new LintMessage($messageArray['line'] ?? null, $fileName, $messageArray['type'] ?? 'ERROR', $messageArray);
46 | }, $messages));
47 | }
48 |
49 | public static function messageToPhpcsArray(LintMessage $message): array {
50 | return array_merge([
51 | 'line' => $message->getLineNumber(),
52 | ], $message->getOtherProperties());
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/tests/helpers/GitFixture.php:
--------------------------------------------------------------------------------
1 | messages = $messages;
22 | }
23 |
24 | /**
25 | * @return static
26 | */
27 | public static function merge(array $messages) {
28 | return self::fromLintMessages(array_merge(...array_map(function(self $message) {
29 | return $message->getMessages();
30 | }, $messages)));
31 | }
32 |
33 | /**
34 | * @return static
35 | */
36 | public static function fromLintMessages(array $messages, string $fileName = null) {
37 | return new static(array_map(function(LintMessage $message) use ($fileName) {
38 | if ($fileName) {
39 | $message->setFile($fileName);
40 | }
41 | return $message;
42 | }, $messages));
43 | }
44 |
45 | /**
46 | * @return LintMessage[]
47 | */
48 | public function getMessages(): array {
49 | return $this->messages;
50 | }
51 |
52 | /**
53 | * @return int[]
54 | */
55 | public function getLineNumbers(): array {
56 | return array_map(function($message) {
57 | return $message->getLineNumber();
58 | }, $this->messages);
59 | }
60 |
61 | /**
62 | * @return static
63 | */
64 | public static function getNewMessages(string $unifiedDiff, self $oldMessages, self $newMessages) {
65 | $map = DiffLineMap::fromUnifiedDiff($unifiedDiff);
66 | $fileName = DiffLineMap::getFileNameFromDiff($unifiedDiff);
67 | return self::fromLintMessages(array_values(array_filter($newMessages->getMessages(), function($newMessage) use ($oldMessages, $map) {
68 | $lineNumber = $newMessage->getLineNumber();
69 | if (! $lineNumber) {
70 | return true;
71 | }
72 | $oldLineNumber = $map->getOldLineNumberForLine($lineNumber);
73 | $oldMessagesContainingOldLineNumber = array_values(array_filter($oldMessages->getMessages(), function($oldMessage) use ($oldLineNumber) {
74 | return $oldMessage->getLineNumber() === $oldLineNumber;
75 | }));
76 | return ! count($oldMessagesContainingOldLineNumber) > 0;
77 | })), $fileName);
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/LintGuard/FileCache.php:
--------------------------------------------------------------------------------
1 | cacheFilePath)) {
20 | return new CacheObject();
21 | }
22 | $contents = file_get_contents($this->cacheFilePath);
23 | if ($contents === false) {
24 | throw new \Exception('Failed to read cache file');
25 | }
26 | $decoded = json_decode($contents, true);
27 | if (! $this->isDecodedDataValid($decoded)) {
28 | throw new \Exception('Invalid cache file');
29 | }
30 | $cacheObject = new CacheObject();
31 | $cacheObject->cacheVersion = $decoded['cacheVersion'];
32 | foreach($decoded['entries'] as $entry) {
33 | if (! $this->isDecodedEntryValid($entry)) {
34 | throw new \Exception('Invalid cache file entry: ' . $entry);
35 | }
36 | $cacheObject->entries[] = CacheEntry::fromJson($entry);
37 | }
38 | return $cacheObject;
39 | }
40 |
41 | public function save(CacheObject $cacheObject): void {
42 | $data = [
43 | 'cacheVersion' => $cacheObject->cacheVersion,
44 | 'entries' => $cacheObject->entries,
45 | ];
46 | $result = file_put_contents($this->cacheFilePath, json_encode($data));
47 | if ($result === false) {
48 | throw new \Exception('Failed to write cache file');
49 | }
50 | }
51 |
52 | /**
53 | * @param mixed $decoded The json-decoded data
54 | */
55 | private function isDecodedDataValid($decoded): bool {
56 | if (! is_array($decoded) ||
57 | ! array_key_exists('cacheVersion', $decoded) ||
58 | ! array_key_exists('entries', $decoded) ||
59 | ! is_array($decoded['entries'])
60 | ) {
61 | return false;
62 | }
63 | if (! is_string($decoded['cacheVersion'])) {
64 | return false;
65 | }
66 | // Note that this does not validate the entries to avoid iterating over
67 | // them twice. That should be done by isDecodedEntryValid.
68 | return true;
69 | }
70 |
71 | private function isDecodedEntryValid(array $entry): bool {
72 | if (! array_key_exists('path', $entry) || ! array_key_exists('data', $entry) || ! array_key_exists('phpcsStandard', $entry) || ! array_key_exists('hash', $entry) || ! array_key_exists('type', $entry)) {
73 | return false;
74 | }
75 | return true;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/tests/helpers/SvnFixture.php:
--------------------------------------------------------------------------------
1 | getFile() ?? 'STDIN';
15 | }, $messages->getMessages()));
16 | if (empty($files)) {
17 | $files = ['STDIN'];
18 | }
19 |
20 | $outputByFile = array_map(function(string $file) use ($messages): array {
21 | $messagesForFile = array_values(array_filter($messages->getMessages(), function(LintMessage $message) use ($file): bool {
22 | return ($message->getFile() ?? 'STDIN') === $file;
23 | }));
24 | return $this->getFormattedMessagesForFile($messagesForFile, $file);
25 | }, $files);
26 |
27 | $errors = array_values(array_filter($messages->getMessages(), function($message) {
28 | return $message->getType() === 'ERROR';
29 | }));
30 | $warnings = array_values(array_filter($messages->getMessages(), function($message) {
31 | return $message->getType() === 'WARNING';
32 | }));
33 | $messages = array_map(function($message) {
34 | return PhpcsMessagesHelpers::messageToPhpcsArray($message);
35 | }, $messages->getMessages());
36 | $dataForJson = [
37 | 'totals' => [
38 | 'errors' => count($errors),
39 | 'warnings' => count($warnings),
40 | 'fixable' => 0,
41 | ],
42 | 'files' => array_merge(...$outputByFile),
43 | ];
44 | $output = json_encode($dataForJson, JSON_UNESCAPED_SLASHES);
45 | if (! $output) {
46 | throw new \Exception('Failed to JSON-encode result messages');
47 | }
48 | return $output;
49 | }
50 |
51 | private function getFormattedMessagesForFile(array $messages, string $file): array {
52 | $errors = array_values(array_filter($messages, function($message) {
53 | return $message->getType() === 'ERROR';
54 | }));
55 | $warnings = array_values(array_filter($messages, function($message) {
56 | return $message->getType() === 'WARNING';
57 | }));
58 | $messageArrays = array_map(function(LintMessage $message): array {
59 | return PhpcsMessagesHelpers::messageToPhpcsArray($message);
60 | }, $messages);
61 | $dataForJson = [
62 | $file => [
63 | 'errors' => count($errors),
64 | 'warnings' => count($warnings),
65 | 'messages' => $messageArrays,
66 | ],
67 | ];
68 | return $dataForJson;
69 | }
70 |
71 | public function getExitCode(PhpcsMessages $messages): int {
72 | return (count($messages->getMessages()) > 0) ? 1 : 0;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/bin/lintguard:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | linter)) {
65 | printErrorAndExit('You must specify a linter.');
66 | exit(1);
67 | }
68 |
69 | $debug = getDebug($cliOptions);
70 | $debug('Options: ' . json_encode($options));
71 |
72 | $configJson = file_get_contents($cliOptions->configPath) ?: '{}';
73 | $cliOptions->config = Config::fromJson($configJson);
74 |
75 | $diffFile = $options['diff'] ?? null;
76 | $oldLintFile = $options['previous-lint'] ?? null;
77 | $newLintFile = $options['new-lint'] ?? null;
78 |
79 | if ($diffFile && $oldLintFile && $newLintFile) {
80 | reportMessagesAndExit(
81 | runManualWorkflow($diffFile, $oldLintFile, $newLintFile),
82 | $cliOptions
83 | );
84 | return;
85 | }
86 |
87 | if ($cliOptions->svnMode) {
88 | $shell = new UnixShell();
89 | reportMessagesAndExit(
90 | runSvnWorkflow($cliOptions, $shell, new CacheManager($cliOptions, FileCache())),
91 | $cliOptions
92 | );
93 | return;
94 | }
95 |
96 | if ($cliOptions->gitUnstaged || $cliOptions->gitStaged || ! empty($cliOptions->gitBase)) {
97 | $shell = new UnixShell();
98 | reportMessagesAndExit(
99 | runGitWorkflow($cliOptions, $shell, new CacheManager($cliOptions, FileCache())),
100 | $cliOptions
101 | );
102 | return;
103 | }
104 |
105 | if (! $cliOptions->svnMode && ! $cliOptions->gitUnstaged && ! $cliOptions->gitStaged && empty($cliOptions->gitBase)) {
106 | printErrorAndExit('You must use either manual or automatic mode.');
107 | exit(1);
108 | }
109 |
110 | printHelp();
111 | exit(1);
112 |
--------------------------------------------------------------------------------
/tests/helpers/TestShell.php:
--------------------------------------------------------------------------------
1 | registerReadableFileName($fileName);
21 | }
22 | }
23 |
24 | public function registerReadableFileName(string $fileName, bool $override = false): bool {
25 | if (!isset($this->readableFileNames[$fileName]) || $override ) {
26 | $this->readableFileNames[$fileName] = true;
27 | return true;
28 | }
29 | throw new \Exception("Already registered file name: {$fileName}");
30 | }
31 |
32 | public function registerCommand(string $command, string $output, int $return_val = 0, bool $override = false): bool {
33 | if (!isset($this->commands[$command]) || $override) {
34 | $this->commands[$command] = [
35 | 'output' => $output,
36 | 'return_val' => $return_val,
37 | ];
38 | return true;
39 | }
40 | throw new \Exception("Already registered command: {$command}");
41 | }
42 |
43 | public function deregisterCommand(string $command): bool {
44 | if (isset($this->commands[$command])) {
45 | unset($this->commands[$command]);
46 | return true;
47 | }
48 | throw new \Exception("No registered command: {$command}");
49 | }
50 |
51 | public function setFileHash(string $fileName, string $hash): void {
52 | $this->fileHashes[$fileName] = $hash;
53 | }
54 |
55 | public function isReadable(string $fileName): bool {
56 | return isset($this->readableFileNames[$fileName]);
57 | }
58 |
59 | public function exitWithCode(int $code): void {} // phpcs:ignore VariableAnalysis
60 |
61 | public function printError(string $message): void {} // phpcs:ignore VariableAnalysis
62 |
63 | public function validateExecutableExists(string $name, string $command): void {} // phpcs:ignore VariableAnalysis
64 |
65 | public function getFileHash(string $fileName): string {
66 | return $this->fileHashes[$fileName] ?? $fileName;
67 | }
68 |
69 | public function executeCommand(string $command, array &$output = null, int &$return_val = null): string {
70 | foreach ($this->commands as $registeredCommand => $return) {
71 | if ($registeredCommand === substr($command, 0, strlen($registeredCommand)) ) {
72 | $return_val = $return['return_val'];
73 | $output = $return['output'];
74 | $this->commandsCalled[$registeredCommand] = $command;
75 | return $return['output'];
76 | }
77 | }
78 |
79 | throw new \Exception("Unknown command: {$command}");
80 | }
81 |
82 | public function resetCommandsCalled(): void {
83 | $this->commandsCalled = [];
84 | }
85 |
86 | public function wasCommandCalled(string $registeredCommand): bool {
87 | return isset($this->commandsCalled[$registeredCommand]);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/LintGuard/FullReporter.php:
--------------------------------------------------------------------------------
1 | getFile() ?? 'STDIN';
15 | }, $messages->getMessages()));
16 | if (empty($files)) {
17 | $files = ['STDIN'];
18 | }
19 |
20 | $lineCount = count($messages->getMessages());
21 | if ($lineCount < 1) {
22 | return '';
23 | }
24 |
25 | return implode("\n", array_filter(array_map(function(string $file) use ($messages, $options): ?string {
26 | $messagesForFile = array_values(array_filter($messages->getMessages(), function(LintMessage $message) use ($file): bool {
27 | return ($message->getFile() ?? 'STDIN') === $file;
28 | }));
29 | return $this->getFormattedMessagesForFile($messagesForFile, $file, $options);
30 | }, $files)));
31 | }
32 |
33 | private function getFormattedMessagesForFile(array $messages, string $file, array $options): ?string {
34 | $lineCount = count($messages);
35 | if ($lineCount < 1) {
36 | return null;
37 | }
38 | $errorsCount = count(array_values(array_filter($messages, function($message) {
39 | return $message->getType() === 'ERROR';
40 | })));
41 | $warningsCount = count(array_values(array_filter($messages, function($message) {
42 | return $message->getType() === 'WARNING';
43 | })));
44 |
45 | $linePlural = ($lineCount === 1) ? '' : 'S';
46 | $errorPlural = ($errorsCount === 1) ? '' : 'S';
47 | $warningPlural = ($warningsCount === 1) ? '' : 'S';
48 |
49 | $longestNumber = getLongestString(array_map(function(LintMessage $message): int {
50 | return $message->getLineNumber();
51 | }, $messages));
52 |
53 | $formattedLines = implode("\n", array_map(function(LintMessage $message) use ($longestNumber, $options): string {
54 | $source = $message->getSource() ?: 'Unknown';
55 | $sourceString = isset($options['s']) ? " ({$source})" : '';
56 | return sprintf(" %{$longestNumber}d | %s | %s%s", $message->getLineNumber(), $message->getType(), $message->getMessage(), $sourceString);
57 | }, $messages));
58 |
59 | return <<getMessages()) > 0) ? 1 : 0;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/LintGuard/SvnWorkflow.php:
--------------------------------------------------------------------------------
1 | getFile() ?? 'STDIN';
16 | }, $messages->getMessages()));
17 | if (empty($files)) {
18 | $files = ['STDIN'];
19 | }
20 |
21 | $outputByFile = array_reduce($files,function(string $output, string $file) use ($messages): string {
22 | $messagesForFile = array_values(array_filter($messages->getMessages(), static function(LintMessage $message) use ($file): bool {
23 | return ($message->getFile() ?? 'STDIN') === $file;
24 | }));
25 | $output .= $this->getFormattedMessagesForFile($messagesForFile, $file);
26 | return $output;
27 | }, '');
28 |
29 | $phpcsVersion = $this->getPhpcsVersion();
30 |
31 | $output = "\n";
32 | $output .= "\n";
33 | $output .= $outputByFile;
34 | $output .= "\n";
35 |
36 | return $output;
37 | }
38 |
39 | private function getFormattedMessagesForFile(array $messages, string $file): string {
40 | $errorCount = count( array_values(array_filter($messages, function(LintMessage $message) {
41 | return $message->getType() === 'ERROR';
42 | })));
43 | $warningCount = count(array_values(array_filter($messages, function(LintMessage $message) {
44 | return $message->getType() === 'WARNING';
45 | })));
46 | $fixableCount = count(array_values(array_filter($messages, function(LintMessage $message) {
47 | return (bool)$message->getProperty('fixable');
48 | })));
49 | $xmlOutputForFile = "\t\n";
50 | $xmlOutputForFile .= array_reduce($messages, function(string $output, LintMessage $message): string{
51 | $type = strtolower( $message->getType() );
52 | $line = $message->getLineNumber();
53 | $column = $message->getColumn();
54 | $source = $message->getSource();
55 | $severity = $message->getSeverity();
56 | $fixable = $message->getProperty('fixable') ? "1" : "0";
57 | $messageString = $message->getMessage();
58 | $output .= "\t\t<{$type} line=\"{$line}\" column=\"{$column}\" source=\"{$source}\" severity=\"{$severity}\" fixable=\"{$fixable}\">{$messageString}{$type}>\n";
59 | return $output;
60 | },'');
61 | $xmlOutputForFile .= "\t\n";
62 |
63 | return $xmlOutputForFile;
64 | }
65 |
66 | protected function getPhpcsVersion(): string {
67 | $phpcs = getenv('PHPCS') ?: 'phpcs';
68 | $shell = new UnixShell();
69 |
70 | $versionPhpcsOutputCommand = "{$phpcs} --version";
71 | $versionPhpcsOutput = $shell->executeCommand($versionPhpcsOutputCommand);
72 | if (! $versionPhpcsOutput) {
73 | throw new ShellException("Cannot get phpcs version");
74 | }
75 |
76 | $matched = preg_match('/version\\s([0-9.]+)/uim', $versionPhpcsOutput, $matches);
77 | if (empty($matched) || empty($matches[1])) {
78 | throw new ShellException("Cannot parse phpcs version output");
79 | }
80 |
81 | return $matches[1];
82 | }
83 |
84 | public function getExitCode(PhpcsMessages $messages): int {
85 | return (count($messages->getMessages()) > 0) ? 1 : 0;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/tests/PhpcsMessagesTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($expected, $messages->getLineNumbers());
15 | }
16 |
17 | public function testFromPhpcsJsonWithEmptyJson() {
18 | $expected = [];
19 | $json = '';
20 | $messages = PhpcsMessages::fromPhpcsJson($json);
21 | $this->assertEquals($expected, $messages->getLineNumbers());
22 | }
23 |
24 | public function testGetPhpcsJson() {
25 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"STDIN":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}';
26 | $messages = PhpcsMessages::fromArrays([
27 | [
28 | 'type' => 'WARNING',
29 | 'severity' => 5,
30 | 'fixable' => false,
31 | 'column' => 5,
32 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
33 | 'line' => 20,
34 | 'message' => 'Found unused symbol Emergent.',
35 | ],
36 | ]);
37 | $this->assertEquals($expected, $messages->toPhpcsJson());
38 | }
39 |
40 | public function testMerge() {
41 | $expected = PhpcsMessages::fromArrays([
42 | [
43 | 'type' => 'WARNING',
44 | 'severity' => 5,
45 | 'fixable' => false,
46 | 'column' => 5,
47 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
48 | 'line' => 15,
49 | 'message' => 'Found unused symbol Foo.',
50 | ],
51 | [
52 | 'type' => 'WARNING',
53 | 'severity' => 5,
54 | 'fixable' => false,
55 | 'column' => 5,
56 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
57 | 'line' => 18,
58 | 'message' => 'Found unused symbol Baz.',
59 | ],
60 | [
61 | 'type' => 'WARNING',
62 | 'severity' => 5,
63 | 'fixable' => false,
64 | 'column' => 5,
65 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
66 | 'line' => 20,
67 | 'message' => 'Found unused symbol Bar.',
68 | ],
69 | ]);
70 | $messagesA = PhpcsMessages::fromArrays([
71 | [
72 | 'type' => 'WARNING',
73 | 'severity' => 5,
74 | 'fixable' => false,
75 | 'column' => 5,
76 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
77 | 'line' => 15,
78 | 'message' => 'Found unused symbol Foo.',
79 | ],
80 | ]);
81 | $messagesB = PhpcsMessages::fromArrays([
82 | [
83 | 'type' => 'WARNING',
84 | 'severity' => 5,
85 | 'fixable' => false,
86 | 'column' => 5,
87 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
88 | 'line' => 18,
89 | 'message' => 'Found unused symbol Baz.',
90 | ],
91 | ]);
92 | $messagesC = PhpcsMessages::fromArrays([
93 | [
94 | 'type' => 'WARNING',
95 | 'severity' => 5,
96 | 'fixable' => false,
97 | 'column' => 5,
98 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
99 | 'line' => 20,
100 | 'message' => 'Found unused symbol Bar.',
101 | ],
102 | ]);
103 | $messages = PhpcsMessages::merge([$messagesA, $messagesB, $messagesC]);
104 | $this->assertEquals($expected->getMessages(), $messages->getMessages());
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/LintGuard/DiffLineMap.php:
--------------------------------------------------------------------------------
1 | diffLines = $diffLines;
14 | }
15 |
16 | public function getOldLineNumberForLine(int $lineNumber): ?int {
17 | foreach ($this->diffLines as $diffLine) {
18 | if ($diffLine->getNewLineNumber() === $lineNumber) {
19 | return $diffLine->getOldLineNumber();
20 | }
21 | }
22 | // go through each changed line in the new file (each DiffLine of type context or add)
23 | // if the new line number is greater than the line number we are looking for
24 | // then add the last difference between the old and new lines to the line number we are looking for
25 | $lineNumberDelta = 0;
26 | $lastOldLine = 0;
27 | $lastNewLine = 0;
28 | foreach ($this->diffLines as $diffLine) {
29 | $lastOldLine = $diffLine->getOldLineNumber() ?? $lastOldLine;
30 | $lastNewLine = $diffLine->getNewLineNumber() ?? $lastNewLine;
31 | if ($diffLine->getType()->isRemove()) {
32 | continue;
33 | }
34 | if (($diffLine->getNewLineNumber() ?? 0) > $lineNumber) {
35 | return intval( $lineNumber + $lineNumberDelta );
36 | }
37 | $lineNumberDelta = ($diffLine->getOldLineNumber() ?? 0) - ($diffLine->getNewLineNumber() ?? 0);
38 | }
39 | return $lastOldLine + ($lineNumber - $lastNewLine);
40 | }
41 |
42 | public static function fromUnifiedDiff(string $unifiedDiff): DiffLineMap {
43 | $diffStringLines = preg_split("/\r\n|\n|\r/", $unifiedDiff) ?: [];
44 | $oldStartLine = $newStartLine = null;
45 | $currentOldLine = $currentNewLine = null;
46 | $lines = [];
47 | foreach ($diffStringLines as $diffStringLine) {
48 |
49 | // Find the start of a hunk
50 | $matches = [];
51 | if (1 === preg_match('/^@@ \-(\d+),(\d+) \+(\d+),(\d+) @@/', $diffStringLine, $matches)) {
52 | $oldStartLine = $matches[1] ?? null;
53 | $newStartLine = $matches[3] ?? null;
54 | $currentOldLine = $oldStartLine;
55 | $currentNewLine = $newStartLine;
56 | continue;
57 | }
58 |
59 | // Ignore headers
60 | if (self::isLineDiffHeader($diffStringLine)) {
61 | continue;
62 | }
63 |
64 | // Parse a hunk
65 | if ($oldStartLine !== null && $newStartLine !== null) {
66 | $lines[] = new DiffLine((int)$currentOldLine, (int)$currentNewLine, self::getDiffLineTypeForLine($diffStringLine), $diffStringLine);
67 | if (self::isLineDiffRemoval($diffStringLine)) {
68 | $currentOldLine ++;
69 | } else if (self::isLineDiffAddition($diffStringLine)) {
70 | $currentNewLine ++;
71 | } else {
72 | $currentOldLine ++;
73 | $currentNewLine ++;
74 | }
75 | }
76 | }
77 | return new DiffLineMap($lines);
78 | }
79 |
80 | public static function getFileNameFromDiff(string $unifiedDiff): ?string {
81 | $diffStringLines = preg_split("/\r\n|\n|\r/", $unifiedDiff) ?: [];
82 | foreach ($diffStringLines as $diffStringLine) {
83 | $matches = [];
84 | if (1 === preg_match('/^\+\+\+ (\S+)/', $diffStringLine, $matches)) {
85 | return $matches[1] ?? null;
86 | }
87 | }
88 | return null;
89 | }
90 |
91 | private static function getDiffLineTypeForLine(string $line): DiffLineType {
92 | if (self::isLineDiffRemoval($line)) {
93 | return DiffLineType::makeRemove();
94 | } else if (self::isLineDiffAddition($line)) {
95 | return DiffLineType::makeAdd();
96 | }
97 | return DiffLineType::makeContext();
98 | }
99 |
100 | private static function isLineDiffHeader(string $line): bool {
101 | return (1 === preg_match('/^Index: /', $line) || 1 === preg_match('/^====/', $line) || 1 === preg_match('/^\-\-\-/', $line) || 1 === preg_match('/^\+\+\+/', $line));
102 | }
103 |
104 | private static function isLineDiffRemoval(string $line): bool {
105 | return (1 === preg_match('/^\-/', $line));
106 | }
107 |
108 | private static function isLineDiffAddition(string $line): bool {
109 | return (1 === preg_match('/^\+/', $line));
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Run various code linters but only report messages caused by recent changes.
2 |
3 | ## IN PROGRESS
4 |
5 | This is not ready yet.
6 |
7 | ## What is this for?
8 |
9 | Let's say that you need to add a feature to a large legacy file which has many linter errors. If you try to run your linters on that file, there may be so much noise it's impossible to notice any errors which you may have added yourself.
10 |
11 | Using this in place of your linter will report messages that apply only to the changes you have made and ignores any messages that were there previously.
12 |
13 | ## Installation
14 |
15 | ```
16 | composer global require sirbrillig/lintguard
17 | ```
18 |
19 | ## CLI Usage
20 |
21 | 👩💻👩💻👩💻
22 |
23 | First you must specify a linter to use using the `--linter ` option.
24 |
25 | Next, you need to be able to provide data about the previous and current versions of your code. `lintguard` can get this data itself using svn or git.
26 |
27 | Here's an example using `lintguard` with the `--svn` option:
28 |
29 | ```
30 | lintguard --linter phpcs --svn
31 | ```
32 |
33 | This will output something like:
34 |
35 | ```
36 | file.php
37 | 76:3 warning Variable $foobar is undefined.
38 | 78:16 warning Variable $barfoo is undefined.
39 |
40 | 2 problems (0 errors, 2 warnings)
41 | ```
42 |
43 | Or, with `--report json`:
44 |
45 | ```json
46 | {
47 | "totals": {
48 | "errors": 0,
49 | "warnings": 2,
50 | },
51 | "files": {
52 | "file.php": {
53 | "errors": 0,
54 | "warnings": 2,
55 | "messages": [
56 | {
57 | "line": 76,
58 | "message": "Variable $foobar is undefined.",
59 | "source": "VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable",
60 | "severity": 5,
61 | "type": "warning",
62 | "column": 3
63 | },
64 | {
65 | "line": 78,
66 | "message": "Variable $barfoo is undefined.",
67 | "source": "VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable",
68 | "severity": 5,
69 | "type": "warning",
70 | "column": 16
71 | }
72 | ]
73 | }
74 | }
75 | }
76 | ```
77 |
78 | If the file was versioned by git, we can do the same with the various git options:
79 |
80 | ```
81 | lintguard --linter phpcs --git-unstaged
82 | ```
83 |
84 | When using git mode, you must specify `--git-staged`, `--git-unstaged`, or `--git-base`.
85 |
86 | `--git-staged` compares the currently staged changes (as the new version of the files) to the current HEAD (as the previous version of the files). This is similar to `git diff --staged`.
87 |
88 | `--git-unstaged` compares the current (unstaged) working copy changes (as the new version of the files) to the either the currently staged changes, or if there are none, the current HEAD (as the previous version of the files). This is similar to `git diff`.
89 |
90 | `--git-base`, followed by a git object, compares the current HEAD (as the new version of the files) to the specified [git object](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) (as the previous version of the file) which can be a branch name, a commit, or some other valid git object.
91 |
92 | ```
93 | git checkout add-new-feature
94 | lintguard --linter phpcs --git-base trunk
95 | ```
96 |
97 | **Note that the output of `lintguard` will be in the same format no matter which linter you use; this is a format specific to lintguard and likely will not match the typical output of the underlying linter being used, although you can write custom reporters.**
98 |
99 | ### CLI Options
100 |
101 | Each linter uses a different method to get its list of files and other configration options. While this library comes with a set of defaults, you should probably create a [config file](#Configfile) to specify the options you want to use.
102 |
103 | By default, linters will be run on an entire project, but if you want to run the linter on a specific file or set of files, you should customize the options.
104 |
105 | You can use `--report` to customize the output type. `human` (the default) is human-readable and `json` prints a JSON object as shown above.
106 |
107 | The `--cache` option will enable caching of linter output and can significantly improve performance for slow linters or when running with high frequency. There are actually two caches: one for the scan of the previous version of the file and one for the scan of the new version. The previous version output cache is invalidated when the .version control revision change version of the file changes. The new version output cache is invalidated when the new file changes.
108 |
109 | The `--no-cache` option will disable the cache if it's been enabled.
110 |
111 | The `--clear-cache` option will clear the cache before running. This works with or without caching enabled.
112 |
113 | ### Config file
114 |
115 | By default, `lintguard` will look for a file named `.lintguardrc.json` in the directory where it is invoked. You can instead specify a path to a config file with the `--config ` option.
116 |
117 | Each linter accepts two options: `command` (a string with the path to the linter) and `args` (an array of arguments to pass to the linter).
118 |
119 | All settings in the config file are optional, but here's example values:
120 |
121 | ```json
122 | {
123 | "version-control": {
124 | "svn": "/usr/bin/svn",
125 | "git": "/usr/local/bin/git"
126 | },
127 | "linter-options": {
128 | "phpcs": {
129 | "command": "/usr/local/bin/phpcs",
130 | "args": [ "--standard=MyCustomStandard", "--ignore=tests", "**/*.php" ]
131 | },
132 | "tsc": {
133 | "command": "/usr/local/bin/tsc",
134 | "args": [ "-p .tsconfig.json" ]
135 | }
136 | }
137 | }
138 | ```
139 |
140 | ## Running Tests
141 |
142 | Run the following commands in this directory to run the built-in test suite:
143 |
144 | ```
145 | composer install
146 | composer test
147 | ```
148 |
149 | You can also run linting and static analysis:
150 |
151 | ```
152 | composer lint
153 | composer phpstan
154 | ```
155 |
156 | ## Inspiration
157 |
158 | This is based on my previous work in [phpcs-changed](https://github.com/sirbrillig/phpcs-changed).
159 |
--------------------------------------------------------------------------------
/tests/DiffLineMapTest.php:
--------------------------------------------------------------------------------
1 | assertEquals('bin/review-stuck-orders.php', $name);
27 | }
28 |
29 | public function testGetLineNumberForSimpleAdd() {
30 | $diff = <<assertNull($map->getOldLineNumberForLine(20));
46 | $this->assertEquals(17, $map->getOldLineNumberForLine(17));
47 | $this->assertEquals(20, $map->getOldLineNumberForLine(21));
48 | $this->assertEquals(21, $map->getOldLineNumberForLine(22));
49 | }
50 |
51 | public function testGetLineNumberForAllRemovalDiff() {
52 | $diff = <<assertEquals(1, $map->getOldLineNumberForLine(1));
82 | $this->assertEquals(3, $map->getOldLineNumberForLine(2));
83 | $this->assertEquals(4, $map->getOldLineNumberForLine(3));
84 | $this->assertEquals(7, $map->getOldLineNumberForLine(6));
85 | $this->assertEquals(12, $map->getOldLineNumberForLine(9));
86 | $this->assertEquals(14, $map->getOldLineNumberForLine(11));
87 | $this->assertEquals(22, $map->getOldLineNumberForLine(17));
88 | }
89 |
90 | public function testGetLineNumberForMixedDiff() {
91 | $diff = <<assertEquals(3, $map->getOldLineNumberForLine(1));
114 | $this->assertNull($map->getOldLineNumberForLine(2));
115 | $this->assertNull($map->getOldLineNumberForLine(3));
116 | $this->assertEquals(5, $map->getOldLineNumberForLine(4));
117 | $this->assertEquals(6, $map->getOldLineNumberForLine(5));
118 | $this->assertEquals(9, $map->getOldLineNumberForLine(8));
119 | $this->assertEquals(10, $map->getOldLineNumberForLine(9));
120 | $this->assertNull($map->getOldLineNumberForLine(11));
121 | $this->assertNull($map->getOldLineNumberForLine(12));
122 | $this->assertNull($map->getOldLineNumberForLine(13));
123 | }
124 |
125 | public function testGetLineNumberForDiffWithOriginalAndNewErrorsOnSameLines() {
126 | $diff = <<assertNull($map->getOldLineNumberForLine(20));
143 | }
144 |
145 | public function testGetLineNumberOutsideOfHunks() {
146 | $diff = <<assertEquals(8, $map->getOldLineNumberForLine(7));
169 | $this->assertEquals(12, $map->getOldLineNumberForLine(14));
170 | $this->assertEquals(13, $map->getOldLineNumberForLine(15));
171 | $this->assertEquals(15, $map->getOldLineNumberForLine(17));
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/LintGuard/CacheManager.php:
--------------------------------------------------------------------------------
1 | >>>
21 | */
22 | private $fileDataByPath = [];
23 |
24 | /**
25 | * @var bool
26 | */
27 | private $hasBeenModified = false;
28 |
29 | /**
30 | * @var CacheInterface
31 | */
32 | private $cache;
33 |
34 | /**
35 | * @var CacheObject
36 | */
37 | private $cacheObject;
38 |
39 | /**
40 | * @var callable
41 | */
42 | private $debug;
43 |
44 | public function __construct(CliOptions $options, CacheInterface $cache) {
45 | $this->cache = $cache;
46 | $this->debug = getDebug($options);
47 | }
48 |
49 | public function load(): void {
50 | ($this->debug)("Loading cache...");
51 | $this->cacheObject = $this->cache->load();
52 |
53 | // Don't try to use old cache versions
54 | $version = getVersion();
55 | if (! $this->cacheObject->cacheVersion) {
56 | $this->cacheObject->cacheVersion = $version;
57 | }
58 | if ($this->cacheObject->cacheVersion !== $version) {
59 | ($this->debug)("Cache version has changed ({$this->cacheObject->cacheVersion} -> {$version}). Clearing cache.");
60 | $this->clearCache();
61 | $this->cacheObject->cacheVersion = $version;
62 | }
63 |
64 | // Keep a map of cache data so it's faster to access
65 | foreach($this->cacheObject->entries as $entry) {
66 | $this->addCacheEntry($entry);
67 | }
68 |
69 | $this->hasBeenModified = false;
70 | ($this->debug)("Cache loaded.");
71 | }
72 |
73 | public function save(): void {
74 | if (! $this->hasBeenModified) {
75 | ($this->debug)("Not saving cache. It is unchanged.");
76 | return;
77 | }
78 | ($this->debug)("Saving cache.");
79 |
80 | // Copy cache data map back to object
81 | $this->cacheObject->entries = $this->getEntries();
82 |
83 | $this->cache->save($this->cacheObject);
84 | $this->hasBeenModified = false;
85 | }
86 |
87 | public function getCacheVersion(): string {
88 | return $this->cacheObject->cacheVersion;
89 | }
90 |
91 | /**
92 | * @return CacheEntry[]
93 | */
94 | public function getEntries(): array {
95 | return $this->flattenArray($this->fileDataByPath);
96 | }
97 |
98 | /**
99 | * Flatten an array
100 | *
101 | * From https://stackoverflow.com/questions/1319903/how-to-flatten-a-multidimensional-array
102 | *
103 | * @param array|CacheEntry $array
104 | */
105 | private function flattenArray($array): array {
106 | if (!is_array($array)) {
107 | // nothing to do if it's not an array
108 | return array($array);
109 | }
110 |
111 | $result = array();
112 | foreach ($array as $value) {
113 | // explode the sub-array, and add the parts
114 | $result = array_merge($result, $this->flattenArray($value));
115 | }
116 |
117 | return $result;
118 | }
119 |
120 | public function setCacheVersion(string $cacheVersion): void {
121 | if ($this->cacheObject->cacheVersion === $cacheVersion) {
122 | return;
123 | }
124 | ($this->debug)("Cache version has changed ('{$this->cacheObject->cacheVersion}' -> '{$cacheVersion}'). Clearing cache.");
125 | $this->hasBeenModified = true;
126 | $this->clearCache();
127 | $this->cacheObject->cacheVersion = $cacheVersion;
128 | }
129 |
130 | public function getCacheForFile(string $filePath, string $type, string $hash, string $phpcsStandard): ?string {
131 | $entry = $this->fileDataByPath[$filePath][$type][$hash][$phpcsStandard] ?? null;
132 | if (! $entry) {
133 | ($this->debug)("Cache miss: file '{$filePath}', hash '{$hash}', standard '{$phpcsStandard}'");
134 | return null;
135 | }
136 | return $entry->data;
137 | }
138 |
139 | public function setCacheForFile(string $filePath, string $type, string $hash, string $phpcsStandard, string $data): void {
140 | $this->hasBeenModified = true;
141 | $entry = new CacheEntry();
142 | $entry->phpcsStandard = $phpcsStandard;
143 | $entry->hash = $hash;
144 | $entry->data = $data;
145 | $entry->path = $filePath;
146 | $entry->type = $type;
147 | $this->addCacheEntry($entry);
148 | }
149 |
150 | public function addCacheEntry(CacheEntry $entry): void {
151 | $this->hasBeenModified = true;
152 | $this->pruneOldEntriesForFile($entry);
153 | if (! isset($this->fileDataByPath[$entry->path])) {
154 | $this->fileDataByPath[$entry->path] = [];
155 | }
156 | if (! isset($this->fileDataByPath[$entry->path][$entry->type])) {
157 | $this->fileDataByPath[$entry->path][$entry->type] = [];
158 | }
159 | if (! isset($this->fileDataByPath[$entry->path][$entry->type][$entry->hash])) {
160 | $this->fileDataByPath[$entry->path][$entry->type][$entry->hash] = [];
161 | }
162 | $this->fileDataByPath[$entry->path][$entry->type][$entry->hash][$entry->phpcsStandard] = $entry;
163 | ($this->debug)("Cache add: file '{$entry->path}', type '{$entry->type}', hash '{$entry->hash}', standard '{$entry->phpcsStandard}'");
164 | }
165 |
166 | private function pruneOldEntriesForFile(CacheEntry $newEntry): void {
167 | foreach ($this->getEntries() as $oldEntry) {
168 | if ($this->shouldEntryBeRemoved($oldEntry, $newEntry)) {
169 | $this->removeCacheEntry($oldEntry);
170 | }
171 | }
172 | }
173 |
174 | private function shouldEntryBeRemoved(CacheEntry $oldEntry, CacheEntry $newEntry): bool {
175 | if ($oldEntry->path === $newEntry->path && $oldEntry->type === $newEntry->type && $oldEntry->phpcsStandard === $newEntry->phpcsStandard) {
176 | return true;
177 | }
178 | return false;
179 | }
180 |
181 | public function removeCacheEntry(CacheEntry $entry): void {
182 | if (isset($this->fileDataByPath[$entry->path][$entry->type][$entry->hash][$entry->phpcsStandard])) {
183 | ($this->debug)("Cache remove: file '{$entry->path}', type '{$entry->type}', hash '{$entry->hash}', standard '{$entry->phpcsStandard}'");
184 | unset($this->fileDataByPath[$entry->path][$entry->type][$entry->hash][$entry->phpcsStandard]);
185 | }
186 | }
187 |
188 | public function clearCache(): void {
189 | ($this->debug)("Cache cleared");
190 | $this->hasBeenModified = true;
191 | $this->fileDataByPath = [];
192 | $this->cacheObject->entries = [];
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/tests/fixtures/old-phpcs-output.json:
--------------------------------------------------------------------------------
1 | {"totals":{"errors":0,"warnings":56,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":56,"messages":[{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Import","severity":5,"type":"WARNING","line":20,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":99,"column":16,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":108,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":111,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":114,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":127,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":135,"column":19,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":232,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":257,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":267,"column":54,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":268,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":278,"column":53,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":279,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":299,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":304,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":314,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":321,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":328,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":329,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":330,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":336,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":344,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":351,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":352,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":358,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":365,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":373,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":381,"column":9,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":384,"column":40,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":411,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":416,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":422,"column":24,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":441,"column":37,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":445,"column":37,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":449,"column":15,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":450,"column":13,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":457,"column":21,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":460,"column":9,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":463,"column":49,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":466,"column":12,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":469,"column":41,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":472,"column":41,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":486,"column":58,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":487,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":530,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":595,"column":61,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":643,"column":58,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":657,"column":8,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":664,"column":17,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":665,"column":13,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":684,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":690,"column":66,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":691,"column":36,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":711,"column":12,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":716,"column":19,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":766,"column":1,"fixable":false}]}}}
2 |
--------------------------------------------------------------------------------
/LintGuard/GitWorkflow.php:
--------------------------------------------------------------------------------
1 | 'WARNING',
15 | 'severity' => 5,
16 | 'fixable' => false,
17 | 'column' => 5,
18 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
19 | 'line' => 15,
20 | 'message' => 'Found unused symbol Foo.',
21 | ],
22 | ], 'fileA.php');
23 | $expected = <<getFormattedMessages($messages, []);
28 | $this->assertEquals($expected, $result);
29 | }
30 |
31 | public function testSingleWarningWithShowCodeOption() {
32 | $messages = PhpcsMessages::fromArrays([
33 | [
34 | 'type' => 'WARNING',
35 | 'severity' => 5,
36 | 'fixable' => false,
37 | 'column' => 5,
38 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
39 | 'line' => 15,
40 | 'message' => 'Found unused symbol Foo.',
41 | ],
42 | ], 'fileA.php');
43 | $expected = <<getFormattedMessages($messages, ['s' => 1]);
48 | $this->assertEquals($expected, $result);
49 | }
50 |
51 | public function testSingleWarningWithShowCodeOptionAndNoCode() {
52 | $messages = PhpcsMessages::fromArrays([
53 | [
54 | 'type' => 'WARNING',
55 | 'severity' => 5,
56 | 'fixable' => false,
57 | 'column' => 5,
58 | 'line' => 15,
59 | 'message' => 'Found unused symbol Foo.',
60 | ],
61 | ], 'fileA.php');
62 | $expected = <<getFormattedMessages($messages, ['s' => 1]);
67 | $this->assertEquals($expected, $result);
68 | }
69 |
70 | public function testMultipleWarningsWithLongLineNumber() {
71 | $messages = PhpcsMessages::fromArrays([
72 | [
73 | 'type' => 'WARNING',
74 | 'severity' => 5,
75 | 'fixable' => false,
76 | 'column' => 5,
77 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
78 | 'line' => 133825,
79 | 'message' => 'Found unused symbol Foo.',
80 | ],
81 | [
82 | 'type' => 'WARNING',
83 | 'severity' => 5,
84 | 'fixable' => false,
85 | 'column' => 5,
86 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
87 | 'line' => 15,
88 | 'message' => 'Found unused symbol Bar.',
89 | ],
90 | ], 'fileA.php');
91 | $expected = <<getFormattedMessages($messages, []);
96 | $this->assertEquals($expected, $result);
97 | }
98 |
99 | public function testMultipleWarningsErrorsAndFiles() {
100 | $messagesA = PhpcsMessages::fromArrays([
101 | [
102 | 'type' => 'ERROR',
103 | 'severity' => 5,
104 | 'fixable' => true,
105 | 'column' => 2,
106 | 'source' => 'ImportDetection.Imports.RequireImports.Something',
107 | 'line' => 12,
108 | 'message' => 'Found unused symbol Faa.',
109 | ],
110 | [
111 | 'type' => 'ERROR',
112 | 'severity' => 5,
113 | 'fixable' => false,
114 | 'column' => 5,
115 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
116 | 'line' => 15,
117 | 'message' => 'Found unused symbol Foo.',
118 | ],
119 | [
120 | 'type' => 'WARNING',
121 | 'severity' => 5,
122 | 'fixable' => false,
123 | 'column' => 8,
124 | 'source' => 'ImportDetection.Imports.RequireImports.Boom',
125 | 'line' => 18,
126 | 'message' => 'Found unused symbol Bar.',
127 | ],
128 | [
129 | 'type' => 'WARNING',
130 | 'severity' => 5,
131 | 'fixable' => false,
132 | 'column' => 5,
133 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
134 | 'line' => 22,
135 | 'message' => 'Found unused symbol Foo.',
136 | ],
137 | ], 'fileA.php');
138 | $messagesB = PhpcsMessages::fromArrays([
139 | [
140 | 'type' => 'WARNING',
141 | 'severity' => 5,
142 | 'fixable' => false,
143 | 'column' => 5,
144 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop',
145 | 'line' => 30,
146 | 'message' => 'Found unused symbol Hi.',
147 | ],
148 | ], 'fileB.php');
149 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]);
150 | $expected = <<getFormattedMessages($messages, ['s' => 1]);
155 | $this->assertEquals($expected, $result);
156 | }
157 |
158 | public function testNoWarnings() {
159 | $messages = PhpcsMessages::fromArrays([]);
160 | $expected = <<getFormattedMessages($messages, []);
165 | $this->assertEquals($expected, $result);
166 | }
167 |
168 | public function testSingleWarningWithNoFilename() {
169 | $messages = PhpcsMessages::fromArrays([
170 | [
171 | 'type' => 'WARNING',
172 | 'severity' => 5,
173 | 'fixable' => false,
174 | 'column' => 5,
175 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
176 | 'line' => 15,
177 | 'message' => 'Found unused symbol Foo.',
178 | ],
179 | ]);
180 | $expected = <<getFormattedMessages($messages, []);
185 | $this->assertEquals($expected, $result);
186 | }
187 |
188 | public function testGetExitCodeWithMessages() {
189 | $messages = PhpcsMessages::fromArrays([
190 | [
191 | 'type' => 'WARNING',
192 | 'severity' => 5,
193 | 'fixable' => false,
194 | 'column' => 5,
195 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
196 | 'line' => 15,
197 | 'message' => 'Found unused symbol Foo.',
198 | ],
199 | ], 'fileA.php');
200 | $reporter = new JsonReporter();
201 | $this->assertEquals(1, $reporter->getExitCode($messages));
202 | }
203 |
204 | public function testGetExitCodeWithNoMessages() {
205 | $messages = PhpcsMessages::fromArrays([], 'fileA.php');
206 | $reporter = new JsonReporter();
207 | $this->assertEquals(0, $reporter->getExitCode($messages));
208 | }
209 | }
210 |
211 |
--------------------------------------------------------------------------------
/tests/XmlReporterTest.php:
--------------------------------------------------------------------------------
1 | 'WARNING',
16 | 'severity' => 5,
17 | 'fixable' => false,
18 | 'column' => 5,
19 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
20 | 'line' => 15,
21 | 'message' => 'Found unused symbol Foo.',
22 | ],
23 | ], 'fileA.php');
24 | $expected = <<
26 |
27 |
28 | Found unused symbol Foo.
29 |
30 |
31 |
32 | EOF;
33 | $reporter = new TestXmlReporter();
34 | $result = $reporter->getFormattedMessages($messages, []);
35 | $this->assertEquals($expected, $result);
36 | }
37 |
38 | public function testSingleWarningWithShowCodeOption() {
39 | $messages = PhpcsMessages::fromArrays([
40 | [
41 | 'type' => 'WARNING',
42 | 'severity' => 5,
43 | 'fixable' => false,
44 | 'column' => 5,
45 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
46 | 'line' => 15,
47 | 'message' => 'Found unused symbol Foo.',
48 | ],
49 | ], 'fileA.php');
50 | $expected = <<
52 |
53 |
54 | Found unused symbol Foo.
55 |
56 |
57 |
58 | EOF;
59 | $reporter = new TestXmlReporter();
60 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]);
61 | $this->assertEquals($expected, $result);
62 | }
63 |
64 | public function testSingleWarningWithShowCodeOptionAndNoCode() {
65 | $messages = PhpcsMessages::fromArrays([
66 | [
67 | 'type' => 'WARNING',
68 | 'severity' => 5,
69 | 'fixable' => false,
70 | 'column' => 5,
71 | 'line' => 15,
72 | 'message' => 'Found unused symbol Foo.',
73 | ],
74 | ], 'fileA.php');
75 | $expected = <<
77 |
78 |
79 | Found unused symbol Foo.
80 |
81 |
82 |
83 | EOF;
84 | $reporter = new TestXmlReporter();
85 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]);
86 | $this->assertEquals($expected, $result);
87 | }
88 |
89 | public function testMultipleWarningsWithLongLineNumber() {
90 | $messages = PhpcsMessages::fromArrays([
91 | [
92 | 'type' => 'WARNING',
93 | 'severity' => 5,
94 | 'fixable' => false,
95 | 'column' => 5,
96 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
97 | 'line' => 133825,
98 | 'message' => 'Found unused symbol Foo.',
99 | ],
100 | [
101 | 'type' => 'WARNING',
102 | 'severity' => 5,
103 | 'fixable' => false,
104 | 'column' => 5,
105 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
106 | 'line' => 15,
107 | 'message' => 'Found unused symbol Bar.',
108 | ],
109 | ], 'fileA.php');
110 | $expected = <<
112 |
113 |
114 | Found unused symbol Foo.
115 | Found unused symbol Bar.
116 |
117 |
118 |
119 | EOF;
120 | $reporter = new TestXmlReporter();
121 | $result = $reporter->getFormattedMessages($messages, []);
122 | $this->assertEquals($expected, $result);
123 | }
124 |
125 | public function testMultipleWarningsErrorsAndFiles() {
126 | $messagesA = PhpcsMessages::fromArrays([
127 | [
128 | 'type' => 'ERROR',
129 | 'severity' => 5,
130 | 'fixable' => true,
131 | 'column' => 2,
132 | 'source' => 'ImportDetection.Imports.RequireImports.Something',
133 | 'line' => 12,
134 | 'message' => 'Found unused symbol Faa.',
135 | ],
136 | [
137 | 'type' => 'ERROR',
138 | 'severity' => 5,
139 | 'fixable' => false,
140 | 'column' => 5,
141 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
142 | 'line' => 15,
143 | 'message' => 'Found unused symbol Foo.',
144 | ],
145 | [
146 | 'type' => 'WARNING',
147 | 'severity' => 5,
148 | 'fixable' => false,
149 | 'column' => 8,
150 | 'source' => 'ImportDetection.Imports.RequireImports.Boom',
151 | 'line' => 18,
152 | 'message' => 'Found unused symbol Bar.',
153 | ],
154 | [
155 | 'type' => 'WARNING',
156 | 'severity' => 5,
157 | 'fixable' => false,
158 | 'column' => 5,
159 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
160 | 'line' => 22,
161 | 'message' => 'Found unused symbol Foo.',
162 | ],
163 | ], 'fileA.php');
164 | $messagesB = PhpcsMessages::fromArrays([
165 | [
166 | 'type' => 'WARNING',
167 | 'severity' => 5,
168 | 'fixable' => false,
169 | 'column' => 5,
170 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop',
171 | 'line' => 30,
172 | 'message' => 'Found unused symbol Hi.',
173 | ],
174 | ], 'fileB.php');
175 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]);
176 | $expected = <<
178 |
179 |
180 | Found unused symbol Faa.
181 | Found unused symbol Foo.
182 | Found unused symbol Bar.
183 | Found unused symbol Foo.
184 |
185 |
186 | Found unused symbol Hi.
187 |
188 |
189 |
190 | EOF;
191 | $reporter = new TestXmlReporter();
192 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]);
193 | $this->assertEquals($expected, $result);
194 | }
195 |
196 | public function testNoWarnings() {
197 | $messages = PhpcsMessages::fromArrays([]);
198 | $expected = <<
200 |
201 |
202 |
203 |
204 |
205 | EOF;
206 | $reporter = new TestXmlReporter();
207 | $result = $reporter->getFormattedMessages($messages, []);
208 | $this->assertEquals($expected, $result);
209 | }
210 |
211 | public function testSingleWarningWithNoFilename() {
212 | $messages = PhpcsMessages::fromArrays([
213 | [
214 | 'type' => 'WARNING',
215 | 'severity' => 5,
216 | 'fixable' => false,
217 | 'column' => 5,
218 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
219 | 'line' => 15,
220 | 'message' => 'Found unused symbol Foo.',
221 | ],
222 | ]);
223 | $expected = <<
225 |
226 |
227 | Found unused symbol Foo.
228 |
229 |
230 |
231 | EOF;
232 | $reporter = new TestXmlReporter();
233 | $result = $reporter->getFormattedMessages($messages, []);
234 | $this->assertEquals($expected, $result);
235 | }
236 |
237 | public function testGetExitCodeWithMessages() {
238 | $messages = PhpcsMessages::fromArrays([
239 | [
240 | 'type' => 'WARNING',
241 | 'severity' => 5,
242 | 'fixable' => false,
243 | 'column' => 5,
244 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
245 | 'line' => 15,
246 | 'message' => 'Found unused symbol Foo.',
247 | ],
248 | ], 'fileA.php');
249 | $reporter = new TestXmlReporter();
250 | $this->assertEquals(1, $reporter->getExitCode($messages));
251 | }
252 |
253 | public function testGetExitCodeWithNoMessages() {
254 | $messages = PhpcsMessages::fromArrays([], 'fileA.php');
255 | $reporter = new TestXmlReporter();
256 | $this->assertEquals(0, $reporter->getExitCode($messages));
257 | }
258 | }
259 |
--------------------------------------------------------------------------------
/tests/FullReporterTest.php:
--------------------------------------------------------------------------------
1 | 'WARNING',
15 | 'severity' => 5,
16 | 'fixable' => false,
17 | 'column' => 5,
18 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
19 | 'line' => 15,
20 | 'message' => 'Found unused symbol Foo.',
21 | ],
22 | ], 'fileA.php');
23 | $expected = <<getFormattedMessages($messages, []);
35 | $this->assertEquals($expected, $result);
36 | }
37 |
38 | public function testSingleWarningWithShowCodeOption() {
39 | $messages = PhpcsMessages::fromArrays([
40 | [
41 | 'type' => 'WARNING',
42 | 'severity' => 5,
43 | 'fixable' => false,
44 | 'column' => 5,
45 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
46 | 'line' => 15,
47 | 'message' => 'Found unused symbol Foo.',
48 | ],
49 | ], 'fileA.php');
50 | $expected = <<getFormattedMessages($messages, ['s' => 1]);
62 | $this->assertEquals($expected, $result);
63 | }
64 |
65 | public function testSingleWarningWithShowCodeOptionAndNoCode() {
66 | $messages = PhpcsMessages::fromArrays([
67 | [
68 | 'type' => 'WARNING',
69 | 'severity' => 5,
70 | 'fixable' => false,
71 | 'column' => 5,
72 | 'line' => 15,
73 | 'message' => 'Found unused symbol Foo.',
74 | ],
75 | ], 'fileA.php');
76 | $expected = <<getFormattedMessages($messages, ['s' => 1]);
88 | $this->assertEquals($expected, $result);
89 | }
90 |
91 | public function testMultipleWarningsWithLongLineNumber() {
92 | $messages = PhpcsMessages::fromArrays([
93 | [
94 | 'type' => 'WARNING',
95 | 'severity' => 5,
96 | 'fixable' => false,
97 | 'column' => 5,
98 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
99 | 'line' => 133825,
100 | 'message' => 'Found unused symbol Foo.',
101 | ],
102 | [
103 | 'type' => 'WARNING',
104 | 'severity' => 5,
105 | 'fixable' => false,
106 | 'column' => 5,
107 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
108 | 'line' => 15,
109 | 'message' => 'Found unused symbol Bar.',
110 | ],
111 | ], 'fileA.php');
112 | $expected = <<getFormattedMessages($messages, []);
125 | $this->assertEquals($expected, $result);
126 | }
127 |
128 | public function testMultipleWarningsErrorsAndFiles() {
129 | $messagesA = PhpcsMessages::fromArrays([
130 | [
131 | 'type' => 'ERROR',
132 | 'severity' => 5,
133 | 'fixable' => true,
134 | 'column' => 2,
135 | 'source' => 'ImportDetection.Imports.RequireImports.Something',
136 | 'line' => 12,
137 | 'message' => 'Found unused symbol Faa.',
138 | ],
139 | [
140 | 'type' => 'ERROR',
141 | 'severity' => 5,
142 | 'fixable' => false,
143 | 'column' => 5,
144 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
145 | 'line' => 15,
146 | 'message' => 'Found unused symbol Foo.',
147 | ],
148 | [
149 | 'type' => 'WARNING',
150 | 'severity' => 5,
151 | 'fixable' => false,
152 | 'column' => 8,
153 | 'source' => 'ImportDetection.Imports.RequireImports.Boom',
154 | 'line' => 18,
155 | 'message' => 'Found unused symbol Bar.',
156 | ],
157 | [
158 | 'type' => 'WARNING',
159 | 'severity' => 5,
160 | 'fixable' => false,
161 | 'column' => 5,
162 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
163 | 'line' => 22,
164 | 'message' => 'Found unused symbol Foo.',
165 | ],
166 | ], 'fileA.php');
167 | $messagesB = PhpcsMessages::fromArrays([
168 | [
169 | 'type' => 'WARNING',
170 | 'severity' => 5,
171 | 'fixable' => false,
172 | 'column' => 5,
173 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop',
174 | 'line' => 30,
175 | 'message' => 'Found unused symbol Hi.',
176 | ],
177 | ], 'fileB.php');
178 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]);
179 | $expected = <<getFormattedMessages($messages, ['s' => 1]);
202 | $this->assertEquals($expected, $result);
203 | }
204 |
205 | public function testNoWarnings() {
206 | $messages = PhpcsMessages::fromArrays([]);
207 | $expected = <<getFormattedMessages($messages, []);
212 | $this->assertEquals($expected, $result);
213 | }
214 |
215 | public function testSingleWarningWithNoFilename() {
216 | $messages = PhpcsMessages::fromArrays([
217 | [
218 | 'type' => 'WARNING',
219 | 'severity' => 5,
220 | 'fixable' => false,
221 | 'column' => 5,
222 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
223 | 'line' => 15,
224 | 'message' => 'Found unused symbol Foo.',
225 | ],
226 | ]);
227 | $expected = <<getFormattedMessages($messages, []);
239 | $this->assertEquals($expected, $result);
240 | }
241 |
242 | public function testGetExitCodeWithMessages() {
243 | $messages = PhpcsMessages::fromArrays([
244 | [
245 | 'type' => 'WARNING',
246 | 'severity' => 5,
247 | 'fixable' => false,
248 | 'column' => 5,
249 | 'source' => 'ImportDetection.Imports.RequireImports.Import',
250 | 'line' => 15,
251 | 'message' => 'Found unused symbol Foo.',
252 | ],
253 | ], 'fileA.php');
254 | $reporter = new FullReporter();
255 | $this->assertEquals(1, $reporter->getExitCode($messages));
256 | }
257 |
258 | public function testGetExitCodeWithNoMessages() {
259 | $messages = PhpcsMessages::fromArrays([], 'fileA.php');
260 | $reporter = new FullReporter();
261 | $this->assertEquals(0, $reporter->getExitCode($messages));
262 | }
263 | }
264 |
--------------------------------------------------------------------------------
/tests/PhpcsChangedTest.php:
--------------------------------------------------------------------------------
1 | 20 ],
28 | [ 'line' => 99 ],
29 | [ 'line' => 108 ],
30 | [ 'line' => 111 ],
31 | [ 'line' => 114 ],
32 | ];
33 | $newFilePhpcs = [
34 | [ 'line' => 20 ],
35 | [ 'line' => 21 ],
36 | [ 'line' => 100 ],
37 | [ 'line' => 109 ],
38 | [ 'line' => 112 ],
39 | [ 'line' => 115 ],
40 | ];
41 | $actual = getNewPhpcsMessages($diff, PhpcsMessages::fromArrays($oldFilePhpcs), PhpcsMessages::fromArrays($newFilePhpcs));
42 | $expected = PhpcsMessages::fromArrays([
43 | [ 'line' => 20 ],
44 | ]);
45 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers());
46 | }
47 |
48 | public function testGetNewPhpcsMessagesWithNewFile() {
49 | $diff = << 3 ],
62 | ];
63 | $actual = getNewPhpcsMessages($diff, PhpcsMessages::fromArrays($oldFilePhpcs), PhpcsMessages::fromArrays($newFilePhpcs));
64 | $expected = PhpcsMessages::fromArrays([
65 | [ 'line' => 3 ],
66 | ]);
67 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers());
68 | }
69 |
70 | public function testGetNewPhpcsMessagesWithChangedLine() {
71 | $diff = << 20 ],
88 | ];
89 | $newFilePhpcs = [
90 | [ 'line' => 20 ],
91 | ];
92 | $actual = getNewPhpcsMessages($diff, PhpcsMessages::fromArrays($oldFilePhpcs), PhpcsMessages::fromArrays($newFilePhpcs));
93 | $expected = PhpcsMessages::fromArrays([
94 | [ 'line' => 20 ],
95 | ]);
96 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers());
97 | }
98 |
99 | public function testGetNewPhpcsMessagesHasFileName() {
100 | $diff = << 20 ],
116 | [ 'line' => 99 ],
117 | [ 'line' => 108 ],
118 | [ 'line' => 111 ],
119 | [ 'line' => 114 ],
120 | ];
121 | $newFilePhpcs = [
122 | [ 'line' => 20 ],
123 | [ 'line' => 21 ],
124 | [ 'line' => 100 ],
125 | [ 'line' => 109 ],
126 | [ 'line' => 112 ],
127 | [ 'line' => 115 ],
128 | ];
129 | $actual = getNewPhpcsMessages(
130 | $diff,
131 | PhpcsMessages::fromArrays($oldFilePhpcs),
132 | PhpcsMessages::fromArrays($newFilePhpcs)
133 | );
134 | $this->assertEquals('bin/review-stuck-orders.php', $actual->getMessages()[0]->getFile());
135 | }
136 |
137 | public function testGetNewPhpcsMessagesFromFiles() {
138 | $actual = getNewPhpcsMessagesFromFiles(
139 | 'tests/fixtures/review-stuck-orders.diff',
140 | 'tests/fixtures/old-phpcs-output.json',
141 | 'tests/fixtures/new-phpcs-output.json'
142 | );
143 | $expected = PhpcsMessages::fromArrays([
144 | [ 'line' => 20 ],
145 | ]);
146 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers());
147 | }
148 |
149 | public function testGetNewPhpcsMessagesFromFilesHasFileName() {
150 | $actual = getNewPhpcsMessagesFromFiles(
151 | 'tests/fixtures/review-stuck-orders.diff',
152 | 'tests/fixtures/old-phpcs-output.json',
153 | 'tests/fixtures/new-phpcs-output.json'
154 | );
155 | $this->assertEquals('bin/review-stuck-orders.php', $actual->getMessages()[0]->getFile());
156 | }
157 |
158 |
159 | public function testGetNewPhpcsMessagesWithPhpcsJson() {
160 | $diff = <<toPhpcsJson();
177 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}';
178 | $this->assertEquals($expected, $actual);
179 | }
180 |
181 | public function testGetNewPhpcsMessagesWithPhpcsJsonAndNewFile() {
182 | $diff = <<toPhpcsJson();
195 | $expected = '{"totals":{"errors":0,"warnings":2,"fixable":0},"files":{"foo.php":{"errors":0,"warnings":2,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."},{"line":21,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}';
196 | $this->assertEquals($expected, $actual);
197 | }
198 |
199 | public function testGetNewPhpcsMessagesWithPhpcsJsonHasFileNameIfProvided() {
200 | $diff = <<toPhpcsJson();
218 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}';
219 | $this->assertEquals($expected, $actual);
220 | }
221 |
222 | public function testGetNewPhpcsMessagesWithPhpcsJsonAndFilename() {
223 | $diff = <<toPhpcsJson();
240 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}';
241 | $this->assertEquals($expected, $actual);
242 | }
243 |
244 | public function testGetNewPhpcsMessagesWithPhpcsJsonAndErrors() {
245 | $diff = <<toPhpcsJson();
262 | $expected = '{"totals":{"errors":1,"warnings":0,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":1,"warnings":0,"messages":[{"line":20,"type":"ERROR","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}';
263 | $this->assertEquals($expected, $actual);
264 | }
265 | }
266 |
--------------------------------------------------------------------------------
/LintGuard/Cli.php:
--------------------------------------------------------------------------------
1 | debug;
22 | return function(...$outputs) use ($debugEnabled) {
23 | if (! $debugEnabled) {
24 | return;
25 | }
26 | foreach ($outputs as $output) {
27 | fwrite(STDERR, (is_string($output) ? $output : var_export($output, true)) . PHP_EOL);
28 | }
29 | };
30 | }
31 |
32 | function printError(string $output): void {
33 | fwrite(STDERR, 'lintguard: An error occurred.' . PHP_EOL);
34 | fwrite(STDERR, 'ERROR: ' . $output . PHP_EOL);
35 | }
36 |
37 | function printErrorAndExit(string $output): void {
38 | printError($output);
39 | fwrite(STDERR, PHP_EOL . 'Run "lintguard --help" for usage information.'. PHP_EOL);
40 | exit(1);
41 | }
42 |
43 | function getLongestString(array $strings): int {
44 | return array_reduce($strings, function(int $length, string $string): int {
45 | return ($length > strlen($string)) ? $length : strlen($string);
46 | }, 0);
47 | }
48 |
49 | function printTwoColumns(array $columns, string $indent): void {
50 | $longestFirstCol = getLongestString(array_keys($columns));
51 | echo PHP_EOL;
52 | foreach ($columns as $firstCol => $secondCol) {
53 | printf("%s%{$longestFirstCol}s\t%s" . PHP_EOL, $indent, $firstCol, $secondCol);
54 | }
55 | echo PHP_EOL;
56 | }
57 |
58 | function printVersion(): void {
59 | $version = getVersion();
60 | echo <<` option. For
71 | example, to run phpcs, use `--linter phpcs`.
72 |
73 | Then you must provide the previous and new versions of the linter output and
74 | the diff showing the changes between the two.
75 |
76 | lintguard can be run in two modes: manual or automatic (recommended).
77 |
78 | Manual Mode:
79 |
80 | In manual mode, three arguments are required to collect the information
81 | needed:
82 |
83 | EOF;
84 |
85 | printTwoColumns([
86 | '--diff ' => 'A file containing a unified diff of the file changes.',
87 | '--previous-lint ' => 'A file containing the JSON output of the linter run on the unchanged files.',
88 | '--new-lint ' => 'A file containing the JSON output of the linter run on the changed files.',
89 | ], " ");
90 |
91 | echo << 'Assume svn-versioned files.',
103 | '--git-staged' => 'Compare the staged git version to the HEAD version.',
104 | '--git-unstaged' => 'Compare the git working copy version to the staged (or HEAD) version.',
105 | '--git-base