├── .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}\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 ' => 'Compare the git HEAD version to version found in OBJECT which can be a branch, commit, or other git object.', 106 | ], " "); 107 | 108 | echo <<' => 'Path to the config file. Uses .lintguardrc.json in the current directory otherwise.', 128 | '--report ' => 'The output reporter to use. One of "human" (default) or "json".', 129 | '--ignore ' => 'A comma separated list of patterns to ignore files and directories.', 130 | '--debug' => 'Enable debug output.', 131 | '--help' => 'Print this help.', 132 | '--version' => 'Print the current version.', 133 | '--cache' => 'Cache phpcs output for improved performance (no-cache will still disable this).', 134 | '--no-cache' => 'Disable caching of phpcs output (does not remove existing cache).', 135 | '--clear-cache' => 'Clear the cache before running.', 136 | ], " "); 137 | echo <<` option. 143 | 144 | If using automatic mode, this script requires two shell commands: the version 145 | control program ('svn' or 'git') and the linter (eg: 'phpcs'). If those 146 | commands are not in your PATH or you would like to override them, you can use 147 | the `.lintguardrc.json` config file to specify the full path for each one. 148 | 149 | Each linter accepts two options: `command` (a string with the path to the 150 | linter) and `args` (an array of arguments to pass to the linter). 151 | 152 | All settings in the config file are optional, but here's example values: 153 | 154 | { 155 | "version-control": { 156 | "svn": "/usr/bin/svn", 157 | "git": "/usr/local/bin/git" 158 | }, 159 | "linter-options": { 160 | "phpcs": { 161 | "command": "/usr/local/bin/phpcs", 162 | "args": [ "--standard=MyCustomStandard", "--ignore=tests", "**/*.php" ] 163 | }, 164 | "tsc": { 165 | "command": "/usr/local/bin/tsc", 166 | "args": [ "-p .tsconfig.json" ] 167 | } 168 | } 169 | } 170 | 171 | EOF; 172 | } 173 | 174 | function getReporter(string $reportType): Reporter { 175 | switch ($reportType) { 176 | case 'human': 177 | return new FullReporter(); 178 | case 'json': 179 | return new JsonReporter(); 180 | } 181 | printErrorAndExit("Unknown Reporter '{$reportType}'"); 182 | throw new \Exception("Unknown Reporter '{$reportType}'"); // Just in case we don't exit for some reason. 183 | } 184 | 185 | function runManualWorkflow(string $diffFile, string $previousLintOut, string $newLintOut): LintMessages { 186 | try { 187 | return LintMessages::getNewMessages( 188 | $diffFile, 189 | $previousLintOut, 190 | $newLintOut 191 | ); 192 | } catch (\Exception $err) { 193 | printErrorAndExit($err->getMessage()); 194 | throw $err; // Just in case we don't exit 195 | } 196 | } 197 | 198 | function runSvnWorkflow(CliOptions $options, ShellOperator $shell, CacheManager $cache): LintMessages { 199 | $svn = $options->config->svn; 200 | $phpcs = $options->config->phpcs; 201 | 202 | $debug = getDebug($cliOptions); 203 | 204 | try { 205 | $debug('validating executables'); 206 | $shell->validateExecutableExists('svn', $svn); 207 | $shell->validateExecutableExists('phpcs', $phpcs); 208 | $debug('executables are valid'); 209 | } catch( \Exception $err ) { 210 | $shell->printError($err->getMessage()); 211 | $shell->exitWithCode(1); 212 | throw $err; // Just in case we do not actually exit, like in tests 213 | } 214 | 215 | loadCache($cache, $shell, $options); 216 | 217 | $messages = array_map(function(string $svnFile) use ($options, $shell, $cache, $debug): LintMessages { 218 | return runSvnWorkflowForFile($svnFile, $options, $shell, $cache, $debug); 219 | }, $svnFiles); 220 | 221 | saveCache($cache, $shell, $options); 222 | 223 | return LintMessages::merge($messages); 224 | } 225 | 226 | function runSvnWorkflowForFile(string $svnFile, array $options, ShellOperator $shell, CacheManager $cache, callable $debug): PhpcsMessages { 227 | $svn = getenv('SVN') ?: 'svn'; 228 | $phpcs = getenv('PHPCS') ?: 'phpcs'; 229 | $cat = getenv('CAT') ?: 'cat'; 230 | 231 | $phpcsStandard = $options['standard'] ?? null; 232 | $phpcsStandardOption = $phpcsStandard ? ' --standard=' . escapeshellarg($phpcsStandard) : ''; 233 | 234 | try { 235 | if (! $shell->isReadable($svnFile)) { 236 | throw new ShellException("Cannot read file '{$svnFile}'"); 237 | } 238 | $svnFileInfo = getSvnFileInfo($svnFile, $svn, [$shell, 'executeCommand'], $debug); 239 | $unifiedDiff = getSvnUnifiedDiff($svnFile, $svn, [$shell, 'executeCommand'], $debug); 240 | $revisionId = getSvnRevisionId($svnFileInfo); 241 | $isNewFile = isNewSvnFile($svnFileInfo); 242 | 243 | $oldFilePhpcsOutput = ''; 244 | if ( ! $isNewFile ) { 245 | $oldFilePhpcsOutput = isCachingEnabled($options) ? $cache->getCacheForFile($svnFile, 'old', $revisionId, $phpcsStandard ?? '') : null; 246 | if ($oldFilePhpcsOutput) { 247 | $debug("Using cache for old file '{$svnFile}' at revision '{$revisionId}' and standard '{$phpcsStandard}'"); 248 | } 249 | if (! $oldFilePhpcsOutput) { 250 | $debug("Not using cache for old file '{$svnFile}' at revision '{$revisionId}' and standard '{$phpcsStandard}'"); 251 | $oldFilePhpcsOutput = getSvnBasePhpcsOutput($svnFile, $svn, $phpcs, $phpcsStandardOption, [$shell, 'executeCommand'], $debug); 252 | if (isCachingEnabled($options)) { 253 | $cache->setCacheForFile($svnFile, 'old', $revisionId, $phpcsStandard ?? '', $oldFilePhpcsOutput); 254 | } 255 | } 256 | } 257 | 258 | $newFileHash = $shell->getFileHash($svnFile); 259 | $newFilePhpcsOutput = isCachingEnabled($options) ? $cache->getCacheForFile($svnFile, 'new', $newFileHash, $phpcsStandard ?? '') : null; 260 | if ($newFilePhpcsOutput) { 261 | $debug("Using cache for new file '{$svnFile}' at revision '{$revisionId}', hash '{$newFileHash}', and standard '{$phpcsStandard}'"); 262 | } 263 | if (! $newFilePhpcsOutput) { 264 | $debug("Not using cache for new file '{$svnFile}' at revision '{$revisionId}', hash '{$newFileHash}', and standard '{$phpcsStandard}'"); 265 | $newFilePhpcsOutput = getSvnNewPhpcsOutput($svnFile, $phpcs, $cat, $phpcsStandardOption, [$shell, 'executeCommand'], $debug); 266 | if (isCachingEnabled($options)) { 267 | $cache->setCacheForFile($svnFile, 'new', $newFileHash, $phpcsStandard ?? '', $newFilePhpcsOutput); 268 | } 269 | } 270 | } catch( NoChangesException $err ) { 271 | $debug($err->getMessage()); 272 | $unifiedDiff = ''; 273 | $oldFilePhpcsOutput = ''; 274 | $newFilePhpcsOutput = ''; 275 | } catch( \Exception $err ) { 276 | $shell->printError($err->getMessage()); 277 | $shell->exitWithCode(1); 278 | throw $err; // Just in case we do not actually exit, like in tests 279 | } 280 | 281 | $debug('processing data...'); 282 | $fileName = DiffLineMap::getFileNameFromDiff($unifiedDiff); 283 | return getNewPhpcsMessages( 284 | $unifiedDiff, 285 | PhpcsMessages::fromPhpcsJson($oldFilePhpcsOutput, $fileName), 286 | PhpcsMessages::fromPhpcsJson($newFilePhpcsOutput, $fileName) 287 | ); 288 | } 289 | 290 | function runGitWorkflow(array $gitFiles, array $options, ShellOperator $shell, CacheManager $cache, callable $debug): PhpcsMessages { 291 | $git = getenv('GIT') ?: 'git'; 292 | $phpcs = getenv('PHPCS') ?: 'phpcs'; 293 | $cat = getenv('CAT') ?: 'cat'; 294 | 295 | try { 296 | $debug('validating executables'); 297 | $shell->validateExecutableExists('git', $git); 298 | $shell->validateExecutableExists('phpcs', $phpcs); 299 | $shell->validateExecutableExists('cat', $cat); 300 | $debug('executables are valid'); 301 | if (isset($options['git-base']) && ! empty($options['git-base'])) { 302 | $options['git-base'] = getGitMergeBase($git, [$shell, 'executeCommand'], $options, $debug); 303 | } 304 | } catch(\Exception $err) { 305 | $shell->printError($err->getMessage()); 306 | $shell->exitWithCode(1); 307 | throw $err; // Just in case we do not actually exit 308 | } 309 | 310 | loadCache($cache, $shell, $options); 311 | 312 | $phpcsMessages = array_map(function(string $gitFile) use ($options, $shell, $cache, $debug): PhpcsMessages { 313 | return runGitWorkflowForFile($gitFile, $options, $shell, $cache, $debug); 314 | }, $gitFiles); 315 | 316 | saveCache($cache, $shell, $options); 317 | 318 | return PhpcsMessages::merge($phpcsMessages); 319 | } 320 | 321 | function runGitWorkflowForFile(string $gitFile, array $options, ShellOperator $shell, CacheManager $cache, callable $debug): PhpcsMessages { 322 | $git = getenv('GIT') ?: 'git'; 323 | $phpcs = getenv('PHPCS') ?: 'phpcs'; 324 | $cat = getenv('CAT') ?: 'cat'; 325 | 326 | $phpcsStandard = $options['standard'] ?? null; 327 | $phpcsStandardOption = $phpcsStandard ? ' --standard=' . escapeshellarg($phpcsStandard) : ''; 328 | 329 | try { 330 | validateGitFileExists($gitFile, $git, [$shell, 'isReadable'], [$shell, 'executeCommand'], $debug); 331 | $unifiedDiff = getGitUnifiedDiff($gitFile, $git, [$shell, 'executeCommand'], $options, $debug); 332 | $isNewFile = isNewGitFile($gitFile, $git, [$shell, 'executeCommand'], $options, $debug); 333 | $oldFilePhpcsOutput = ''; 334 | if (! $isNewFile) { 335 | $oldFileHash = getOldGitFileHash($gitFile, $git, $cat, [$shell, 'executeCommand'], $options, $debug); 336 | $oldFilePhpcsOutput = isCachingEnabled($options) ? $cache->getCacheForFile($gitFile, 'old', $oldFileHash, $phpcsStandard ?? '') : null; 337 | if ($oldFilePhpcsOutput) { 338 | $debug("Using cache for old file '{$gitFile}' at hash '{$oldFileHash}' with standard '{$phpcsStandard}'"); 339 | } 340 | if (! $oldFilePhpcsOutput) { 341 | $debug("Not using cache for old file '{$gitFile}' at hash '{$oldFileHash}' with standard '{$phpcsStandard}'"); 342 | $oldFilePhpcsOutput = getGitBasePhpcsOutput($gitFile, $git, $phpcs, $phpcsStandardOption, [$shell, 'executeCommand'], $options, $debug); 343 | if (isCachingEnabled($options)) { 344 | $cache->setCacheForFile($gitFile, 'old', $oldFileHash, $phpcsStandard ?? '', $oldFilePhpcsOutput); 345 | } 346 | } 347 | } 348 | 349 | $newFileHash = getNewGitFileHash($gitFile, $git, $cat, [$shell, 'executeCommand'], $options, $debug); 350 | $newFilePhpcsOutput = isCachingEnabled($options) ? $cache->getCacheForFile($gitFile, 'new', $newFileHash, $phpcsStandard ?? '') : null; 351 | if ($newFilePhpcsOutput) { 352 | $debug("Using cache for new file '{$gitFile}' at hash '{$newFileHash}', and standard '{$phpcsStandard}'"); 353 | } 354 | if (! $newFilePhpcsOutput) { 355 | $debug("Not using cache for new file '{$gitFile}' at hash '{$newFileHash}', and standard '{$phpcsStandard}'"); 356 | $newFilePhpcsOutput = getGitNewPhpcsOutput($gitFile, $git, $phpcs, $cat, $phpcsStandardOption, [$shell, 'executeCommand'], $options, $debug); 357 | if (isCachingEnabled($options)) { 358 | $cache->setCacheForFile($gitFile, 'new', $newFileHash, $phpcsStandard ?? '', $newFilePhpcsOutput); 359 | } 360 | } 361 | } catch( NoChangesException $err ) { 362 | $debug($err->getMessage()); 363 | $unifiedDiff = ''; 364 | $oldFilePhpcsOutput = ''; 365 | $newFilePhpcsOutput = ''; 366 | } catch(\Exception $err) { 367 | $shell->printError($err->getMessage()); 368 | $shell->exitWithCode(1); 369 | throw $err; // Just in case we do not actually exit 370 | } 371 | 372 | $debug('processing data...'); 373 | $fileName = DiffLineMap::getFileNameFromDiff($unifiedDiff); 374 | return getNewPhpcsMessages($unifiedDiff, PhpcsMessages::fromPhpcsJson($oldFilePhpcsOutput, $fileName), PhpcsMessages::fromPhpcsJson($newFilePhpcsOutput, $fileName)); 375 | } 376 | 377 | function reportMessagesAndExit(PhpcsMessages $messages, string $reportType, array $options): void { 378 | $reporter = getReporter($reportType); 379 | echo $reporter->getFormattedMessages($messages, $options); 380 | exit($reporter->getExitCode($messages)); 381 | } 382 | 383 | function isCachingEnabled(array $options): bool { 384 | if (isset($options['no-cache'])) { 385 | return false; 386 | } 387 | if (isset($options['cache'])) { 388 | return true; 389 | } 390 | return false; 391 | } 392 | 393 | function loadCache(CacheManager $cache, ShellOperator $shell, array $options): void { 394 | if (isCachingEnabled($options)) { 395 | try { 396 | $cache->load(); 397 | } catch( \Exception $err ) { 398 | $shell->printError($err->getMessage()); 399 | // If there is an invalid cache, we should clear it to be safe 400 | $shell->printError('An error occurred reading the cache so it will now be cleared. Try running your command again.'); 401 | $cache->clearCache(); 402 | saveCache($cache, $shell, $options); 403 | $shell->exitWithCode(1); 404 | throw $err; // Just in case we do not actually exit, like in tests 405 | } 406 | } 407 | 408 | if (isset($options['clear-cache'])) { 409 | $cache->clearCache(); 410 | try { 411 | $cache->save(); 412 | } catch( \Exception $err ) { 413 | $shell->printError($err->getMessage()); 414 | $shell->exitWithCode(1); 415 | throw $err; // Just in case we do not actually exit, like in tests 416 | } 417 | } 418 | } 419 | 420 | function saveCache(CacheManager $cache, ShellOperator $shell, array $options): void { 421 | if (isCachingEnabled($options)) { 422 | try { 423 | $cache->save(); 424 | } catch( \Exception $err ) { 425 | $shell->printError($err->getMessage()); 426 | $shell->printError('An error occurred saving the cache. Try running with caching disabled.'); 427 | $shell->exitWithCode(1); 428 | throw $err; // Just in case we do not actually exit, like in tests 429 | } 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /tests/GitWorkflowTest.php: -------------------------------------------------------------------------------- 1 | fixture = new GitFixture(); 22 | $this->phpcs = new PhpcsFixture(); 23 | } 24 | 25 | public function testIsNewGitFileReturnsTrueForNewFile() { 26 | $gitFile = 'foobar.php'; 27 | $git = 'git'; 28 | $executeCommand = function($command) { 29 | if (false !== strpos($command, "git status --short 'foobar.php'")) { 30 | return $this->fixture->getNewFileInfo('foobar.php'); 31 | } 32 | }; 33 | $this->assertTrue(isNewGitFile($gitFile, $git, $executeCommand, array(), '\LintGuardTests\Debug')); 34 | } 35 | 36 | public function testIsNewGitFileReturnsFalseForOldFile() { 37 | $gitFile = 'foobar.php'; 38 | $git = 'git'; 39 | $executeCommand = function($command) { 40 | if (false !== strpos($command, "git status --short 'foobar.php'")) { 41 | return $this->fixture->getModifiedFileInfo('foobar.php'); 42 | } 43 | }; 44 | $this->assertFalse(isNewGitFile($gitFile, $git, $executeCommand, array(), '\LintGuardTests\Debug')); 45 | } 46 | 47 | public function testGetGitUnifiedDiff() { 48 | $gitFile = 'foobar.php'; 49 | $git = 'git'; 50 | $diff = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 51 | $executeCommand = function($command) use ($diff) { 52 | if (! $command || false === strpos($command, "git diff --staged --no-prefix 'foobar.php'")) { 53 | return ''; 54 | } 55 | return $diff; 56 | }; 57 | $this->assertEquals($diff, getGitUnifiedDiff($gitFile, $git, $executeCommand, [], '\LintGuardTests\Debug')); 58 | } 59 | 60 | public function testFullGitWorkflowForOneFileStaged() { 61 | $gitFile = 'foobar.php'; 62 | $shell = new TestShell([$gitFile]); 63 | $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 64 | $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); 65 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); 66 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); 67 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); 68 | $options = []; 69 | $cache = new CacheManager( new TestCache() ); 70 | $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); 71 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 72 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 73 | } 74 | 75 | public function testFullGitWorkflowForOneFileUnstaged() { 76 | $gitFile = 'foobar.php'; 77 | $shell = new TestShell([$gitFile]); 78 | $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 79 | $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); 80 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); 81 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); 82 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); 83 | $options = ['git-unstaged' => '1']; 84 | $cache = new CacheManager( new TestCache() ); 85 | $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); 86 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 87 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 88 | } 89 | 90 | public function testFullGitWorkflowForOneFileUnstagedCachesDataThenUsesCache() { 91 | $gitFile = 'foobar.php'; 92 | $shell = new TestShell([$gitFile]); 93 | $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 94 | $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); 95 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); 96 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php') | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); 97 | $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); 98 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php') | git hash-object --stdin", 'previous-file-hash'); 99 | $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); 100 | $options = [ 101 | 'git-unstaged' => '1', 102 | 'cache' => false, // getopt is weird and sets options to false 103 | ]; 104 | $cache = new CacheManager( new TestCache(), '\LintGuardTests\Debug' ); 105 | $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); 106 | 107 | runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 108 | 109 | $shell->resetCommandsCalled(); 110 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 111 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 112 | $this->assertFalse($shell->wasCommandCalled("git show :0:$(git ls-files --full-name 'foobar.php') | phpcs")); 113 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); 114 | } 115 | 116 | public function testFullGitWorkflowForOneFileUnstagedCachesDataThenClearsOldCacheWhenOldFileChanges() { 117 | $gitFile = 'foobar.php'; 118 | $shell = new TestShell([$gitFile]); 119 | $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 120 | $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); 121 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); 122 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php') | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); 123 | $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); 124 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php') | git hash-object --stdin", 'previous-file-hash'); 125 | $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); 126 | $options = [ 127 | 'git-unstaged' => '1', 128 | 'cache' => false, // getopt is weird and sets options to false 129 | ]; 130 | $cache = new CacheManager( new TestCache(), '\LintGuardTests\Debug' ); 131 | $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); 132 | 133 | runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 134 | 135 | $shell->deregisterCommand("git show :0:$(git ls-files --full-name 'foobar.php') | git hash-object --stdin"); 136 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php') | git hash-object --stdin", 'old-file-hash-2'); 137 | $shell->resetCommandsCalled(); 138 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 139 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 140 | $this->assertTrue($shell->wasCommandCalled("git show :0:$(git ls-files --full-name 'foobar.php') | phpcs")); 141 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); 142 | } 143 | 144 | public function testFullGitWorkflowForOneFileUnstagedCachesDataThenClearsNewCacheWhenFileChanges() { 145 | $gitFile = 'foobar.php'; 146 | $shell = new TestShell([$gitFile]); 147 | $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 148 | $shell->registerCommand("git diff --no-prefix 'foobar.php'", $fixture); 149 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); 150 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php') | phpcs", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); 151 | $shell->registerCommand("cat 'foobar.php' | phpcs", $this->phpcs->getResults('STDIN', [21, 20], 'Found unused symbol Foobar.')->toPhpcsJson()); 152 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php') | git hash-object --stdin", 'previous-file-hash'); 153 | $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash'); 154 | $options = [ 155 | 'git-unstaged' => '1', 156 | 'cache' => false, // getopt is weird and sets options to false 157 | ]; 158 | $cache = new CacheManager( new TestCache(), '\LintGuardTests\Debug' ); 159 | $expected = $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'); 160 | 161 | runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 162 | 163 | $shell->deregisterCommand("cat 'foobar.php' | git hash-object --stdin"); 164 | $shell->registerCommand("cat 'foobar.php' | git hash-object --stdin", 'new-file-hash-2'); 165 | $shell->resetCommandsCalled(); 166 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 167 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 168 | $this->assertFalse($shell->wasCommandCalled("git show :0:$(git ls-files --full-name 'foobar.php') | phpcs")); 169 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php' | phpcs")); 170 | } 171 | 172 | public function testFullGitWorkflowForMultipleFilesStaged() { 173 | $gitFiles = ['foobar.php', 'baz.php']; 174 | $shell = new TestShell($gitFiles); 175 | $fixture = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 176 | $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); 177 | $fixture = $this->fixture->getAddedLineDiff('baz.php', 'use Baz;'); 178 | $shell->registerCommand("git diff --staged --no-prefix 'baz.php'", $fixture); 179 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getModifiedFileInfo('foobar.php')); 180 | $shell->registerCommand("git status --short 'baz.php'", $this->fixture->getModifiedFileInfo('baz.php')); 181 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); 182 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'baz.php')", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); 183 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Foobar.')->toPhpcsJson()); 184 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'baz.php')", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Baz.')->toPhpcsJson()); 185 | $options = []; 186 | $cache = new CacheManager( new TestCache() ); 187 | $expected = PhpcsMessages::merge([ 188 | $this->phpcs->getResults('bin/foobar.php', [20], 'Found unused symbol Foobar.'), 189 | $this->phpcs->getResults('bin/baz.php', [20], 'Found unused symbol Baz.'), 190 | ]); 191 | $messages = runGitWorkflow($gitFiles, $options, $shell, $cache, '\LintGuardTests\Debug'); 192 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 193 | } 194 | 195 | public function testFullGitWorkflowForUnchangedFileWithPhpcsMessages() { 196 | $gitFile = 'foobar.php'; 197 | $shell = new TestShell([$gitFile]); 198 | $fixture = $this->fixture->getEmptyFileDiff(); 199 | $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); 200 | $shell->registerCommand("git status --short 'foobar.php'", ''); 201 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); 202 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20])->toPhpcsJson()); 203 | $options = []; 204 | $cache = new CacheManager( new TestCache() ); 205 | $expected = PhpcsMessages::fromArrays([], '/dev/null'); 206 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 207 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 208 | } 209 | 210 | public function testFullGitWorkflowForUnchangedFileWithoutPhpcsMessages() { 211 | $gitFile = 'foobar.php'; 212 | $shell = new TestShell([$gitFile]); 213 | $fixture = $this->fixture->getEmptyFileDiff(); 214 | $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); 215 | $shell->registerCommand("git status --short 'foobar.php'", ''); 216 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [])->toPhpcsJson()); 217 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [])->toPhpcsJson()); 218 | $options = []; 219 | $cache = new CacheManager( new TestCache() ); 220 | $expected = PhpcsMessages::fromArrays([], '/dev/null'); 221 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 222 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 223 | } 224 | 225 | public function testFullGitWorkflowForNonGitFile() { 226 | $this->expectException(ShellException::class); 227 | $gitFile = 'foobar.php'; 228 | $shell = new TestShell([$gitFile]); 229 | $fixture = $this->fixture->getEmptyFileDiff(); 230 | $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); 231 | $shell->registerCommand("git status --short 'foobar.php'", "?? foobar.php" ); 232 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'foobar.php')", $this->fixture->getNonGitFileShow('foobar.php'), 128); 233 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [20], 'Found unused symbol Foobar.')->toPhpcsJson()); 234 | $options = []; 235 | $cache = new CacheManager( new TestCache() ); 236 | runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 237 | } 238 | 239 | public function testFullGitWorkflowForNewFile() { 240 | $gitFile = 'foobar.php'; 241 | $shell = new TestShell([$gitFile]); 242 | $fixture = $this->fixture->getNewFileDiff('foobar.php'); 243 | $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); 244 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getNewFileInfo('foobar.php')); 245 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $this->phpcs->getResults('STDIN', [5, 6], 'Found unused symbol Foobar.')->toPhpcsJson()); 246 | $options = []; 247 | $cache = new CacheManager( new TestCache() ); 248 | $expected = $this->phpcs->getResults('bin/foobar.php', [5, 6], 'Found unused symbol Foobar.'); 249 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 250 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 251 | } 252 | 253 | public function testFullGitWorkflowForEmptyNewFile() { 254 | $gitFile = 'foobar.php'; 255 | $shell = new TestShell([$gitFile]); 256 | $fixture = $this->fixture->getNewFileDiff('foobar.php'); 257 | $shell->registerCommand("git diff --staged --no-prefix 'foobar.php'", $fixture); 258 | $shell->registerCommand("git status --short 'foobar.php'", $this->fixture->getNewFileInfo('foobar.php')); 259 | $fixture ='ERROR: You must supply at least one file or directory to process. 260 | 261 | Run "phpcs --help" for usage information 262 | '; 263 | $shell->registerCommand("git show :0:$(git ls-files --full-name 'foobar.php')", $fixture, 1); 264 | 265 | $options = []; 266 | $cache = new CacheManager( new TestCache() ); 267 | $expected = PhpcsMessages::fromArrays([], '/dev/null'); 268 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 269 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 270 | } 271 | 272 | function testFullGitWorkflowForInterBranchDiff() { 273 | $gitFile = 'bin/foobar.php'; 274 | $shell = new TestShell([$gitFile]); 275 | $fixture = $this->fixture->getAltAddedLineDiff('foobar.php', 'use Foobar;'); 276 | $shell->registerCommand("git merge-base 'master' HEAD", "0123456789abcdef0123456789abcdef01234567\n"); 277 | $shell->registerCommand("git diff '0123456789abcdef0123456789abcdef01234567'... --no-prefix 'bin/foobar.php'", $fixture); 278 | $shell->registerCommand("git status --short 'bin/foobar.php'", ''); 279 | $shell->registerCommand("git cat-file -e '0123456789abcdef0123456789abcdef01234567':'bin/foobar.php'", ''); 280 | $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':$(git ls-files --full-name 'bin/foobar.php') | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6], 'Found unused symbol Foobar.')->toPhpcsJson()); 281 | $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':$(git ls-files --full-name 'bin/foobar.php') | git hash-object --stdin", 'previous-file-hash'); 282 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'bin/foobar.php') | phpcs --report=json -q --stdin-path='bin/foobar.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/bin\/foobar.php', [6, 7], 'Found unused symbol Foobar.')->toPhpcsJson()); 283 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'bin/foobar.php') | git hash-object --stdin", 'new-file-hash'); 284 | $options = [ 'git-base' => 'master' ]; 285 | $cache = new CacheManager( new TestCache() ); 286 | $expected = $this->phpcs->getResults('bin/foobar.php', [6], 'Found unused symbol Foobar.'); 287 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 288 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 289 | } 290 | 291 | function testNameDetectionInFullGitWorkflowForInterBranchDiff() { 292 | $gitFile = 'test.php'; 293 | $shell = new TestShell([$gitFile]); 294 | $shell->registerCommand("git status --short 'test.php'", $this->fixture->getModifiedFileInfo('test.php')); 295 | 296 | $fixture = $this->fixture->getAltNewFileDiff('test.php'); 297 | $shell->registerCommand("git merge-base 'master' HEAD", "0123456789abcdef0123456789abcdef01234567\n"); 298 | $shell->registerCommand("git diff '0123456789abcdef0123456789abcdef01234567'... --no-prefix 'test.php'", $fixture); 299 | $shell->registerCommand("git cat-file -e '0123456789abcdef0123456789abcdef01234567':'test.php'", '', 128); 300 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'test.php') | phpcs --report=json -q --stdin-path='test.php' -", $this->phpcs->getResults('\/srv\/www\/wordpress-default\/public_html\/test\/test.php', [6, 7, 8], "Found unused symbol 'Foobar'.")->toPhpcsJson()); 301 | $shell->registerCommand("git show '0123456789abcdef0123456789abcdef01234567':$(git ls-files --full-name 'test.php') | git hash-object --stdin", 'previous-file-hash'); 302 | $shell->registerCommand("git show HEAD:$(git ls-files --full-name 'test.php') | git hash-object --stdin", 'new-file-hash'); 303 | $options = [ 'git-base' => 'master' ]; 304 | $cache = new CacheManager( new TestCache() ); 305 | $expected = PhpcsMessages::merge([ 306 | $this->phpcs->getResults('test.php', [6], "Found unused symbol 'Foobar'."), 307 | $this->phpcs->getResults('test.php', [7], "Found unused symbol 'Foobar'."), 308 | $this->phpcs->getResults('test.php', [8], "Found unused symbol 'Foobar'."), 309 | ]); 310 | $messages = runGitWorkflow([$gitFile], $options, $shell, $cache, '\LintGuardTests\Debug'); 311 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /tests/SvnWorkflowTest.php: -------------------------------------------------------------------------------- 1 | fixture = new SvnFixture(); 22 | $this->phpcs = new PhpcsFixture(); 23 | } 24 | 25 | public function testIsNewSvnFileReturnsTrueForNewFile() { 26 | $svnFile = 'foobar.php'; 27 | $svn = 'svn'; 28 | $executeCommand = function($command) { 29 | if (! $command || false === strpos($command, "svn info 'foobar.php'")) { 30 | return ''; 31 | } 32 | return $this->fixture->getSvnInfoNewFile('foobar.php'); 33 | }; 34 | $svnFileInfo = getSvnFileInfo($svnFile, $svn, $executeCommand, '\LintGuardTests\debug'); 35 | $this->assertTrue(isNewSvnFile($svnFileInfo)); 36 | } 37 | 38 | public function testIsNewSvnFileReturnsFalseForOldFile() { 39 | $svnFile = 'foobar.php'; 40 | $svn = 'svn'; 41 | $executeCommand = function($command) { 42 | if (! $command || false === strpos($command, "svn info 'foobar.php'")) { 43 | return ''; 44 | } 45 | return $this->fixture->getSvnInfo('foobar.php'); 46 | }; 47 | $svnFileInfo = getSvnFileInfo($svnFile, $svn, $executeCommand, '\LintGuardTests\debug'); 48 | $this->assertFalse(isNewSvnFile($svnFileInfo)); 49 | } 50 | 51 | public function testGetSvnUnifiedDiff() { 52 | $svnFile = 'foobar.php'; 53 | $svn = 'svn'; 54 | $diff = $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;'); 55 | $executeCommand = function($command) use ($diff) { 56 | if (! $command || false === strpos($command, "svn diff 'foobar.php'")) { 57 | return ''; 58 | } 59 | return $diff; 60 | }; 61 | $this->assertEquals($diff, getSvnUnifiedDiff($svnFile, $svn, $executeCommand, '\LintGuardTests\debug')); 62 | } 63 | 64 | public function testFullSvnWorkflowForOneFile() { 65 | $svnFile = 'foobar.php'; 66 | $shell = new TestShell([$svnFile]); 67 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 68 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); 69 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 70 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 71 | $options = []; 72 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 73 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 74 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 75 | } 76 | 77 | public function testFullSvnWorkflowForOneFileWithNoMessages() { 78 | $svnFile = 'foobar.php'; 79 | $shell = new TestShell([$svnFile]); 80 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 81 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); 82 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 83 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getEmptyResults()->toPhpcsJson()); 84 | $options = []; 85 | $expected = $this->phpcs->getEmptyResults(); 86 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 87 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 88 | } 89 | 90 | public function testFullSvnWorkflowForOneFileWithCachingEnabledButNoCache() { 91 | $svnFile = 'foobar.php'; 92 | $shell = new TestShell([$svnFile]); 93 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 94 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); 95 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 96 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 97 | $options = [ 98 | 'cache' => false, // getopt is weird and sets options to false 99 | ]; 100 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 101 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 102 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 103 | } 104 | 105 | public function testFullSvnWorkflowForOneFileCached() { 106 | $svnFile = 'foobar.php'; 107 | $shell = new TestShell([$svnFile]); 108 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 109 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 110 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 111 | $options = [ 112 | 'cache' => false, // getopt is weird and sets options to false 113 | ]; 114 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 115 | $cache = new TestCache(); 116 | $cache->setEntry('foobar.php', 'old', '188280', '', $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 117 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager($cache), '\LintGuardTests\debug'); 118 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 119 | $this->assertFalse($shell->wasCommandCalled("svn cat 'foobar.php'")); 120 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 121 | } 122 | 123 | public function testFullSvnWorkflowForOneFileUncachedThenCachesBothVersionsOfTheFile() { 124 | $svnFile = 'foobar.php'; 125 | $shell = new TestShell([$svnFile]); 126 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 127 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 128 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 129 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 130 | $options = [ 131 | 'cache' => false, // getopt is weird and sets options to false 132 | ]; 133 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 134 | 135 | $cache = new TestCache(); 136 | $manager = new CacheManager($cache); 137 | 138 | // Run once to cache results 139 | runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 140 | 141 | // Run again to prove results have been cached 142 | $shell->deregisterCommand("svn cat 'foobar.php'"); 143 | $shell->deregisterCommand("cat 'foobar.php'"); 144 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 145 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 146 | } 147 | 148 | public function testFullSvnWorkflowForOneDoesNotUseNewFileCacheWhenHashChanges() { 149 | $svnFile = 'foobar.php'; 150 | $shell = new TestShell([$svnFile]); 151 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 152 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 153 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 154 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 155 | $options = [ 156 | 'cache' => false, // getopt is weird and sets options to false 157 | ]; 158 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 159 | 160 | $cache = new TestCache(); 161 | $manager = new CacheManager($cache); 162 | 163 | // Run once to cache results 164 | runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 165 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 166 | 167 | // Run again to prove results have been cached 168 | $shell->deregisterCommand("svn cat 'foobar.php'"); 169 | $shell->deregisterCommand("cat 'foobar.php'"); 170 | $shell->resetCommandsCalled(); 171 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 172 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 173 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); 174 | 175 | // Run a third time, with the file hash changed, and make sure we don't use the (new file) cache (the old file cache will still be used because it is not keyed by hash) 176 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 177 | $shell->setFileHash('foobar.php', 'different-hash'); 178 | $shell->resetCommandsCalled(); 179 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 180 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 181 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 182 | } 183 | 184 | public function testFullSvnWorkflowForOneClearsCacheForFileWhenHashChanges() { 185 | $svnFile = 'foobar.php'; 186 | $shell = new TestShell([$svnFile]); 187 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 188 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 189 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 190 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 191 | $options = [ 192 | 'cache' => false, // getopt is weird and sets options to false 193 | ]; 194 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 195 | $original_hash = $shell->getFileHash('foobar.php'); 196 | 197 | $cache = new TestCache(); 198 | $manager = new CacheManager($cache); 199 | 200 | // Run once to cache results 201 | runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 202 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 203 | 204 | // Run again to prove results have been cached 205 | $shell->deregisterCommand("svn cat 'foobar.php'"); 206 | $shell->deregisterCommand("cat 'foobar.php'"); 207 | $shell->resetCommandsCalled(); 208 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 209 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 210 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); 211 | 212 | // Run a third time, with the file hash changed, and make sure we don't use the (new file) cache (the old file cache will still be used because it is not keyed by hash) 213 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 214 | $shell->setFileHash('foobar.php', 'different-hash'); 215 | $shell->resetCommandsCalled(); 216 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 217 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 218 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 219 | 220 | // Run a fourth time, restoring the old hash, and make sure we still don't use the (new file) cache 221 | $shell->setFileHash('foobar.php', $original_hash); 222 | $shell->resetCommandsCalled(); 223 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 224 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 225 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 226 | } 227 | 228 | public function testFullSvnWorkflowForOneDoesNotClearCacheWhenStandardChanges() { 229 | $svnFile = 'foobar.php'; 230 | $shell = new TestShell([$svnFile]); 231 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 232 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 233 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 234 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 235 | $options = [ 236 | 'cache' => false, // getopt is weird and sets options to false 237 | ]; 238 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 239 | 240 | $cache = new TestCache(); 241 | $manager = new CacheManager($cache); 242 | 243 | // Run once to cache results 244 | $options['standard'] = 'one'; 245 | runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 246 | $this->assertTrue($shell->wasCommandCalled("svn cat 'foobar.php'")); 247 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 248 | 249 | // Run again to prove results have been cached 250 | $shell->resetCommandsCalled(); 251 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 252 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 253 | $this->assertFalse($shell->wasCommandCalled("svn cat 'foobar.php'")); 254 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); 255 | 256 | // Run a third time, with the standard changed, and make sure we don't use the cache 257 | $options['standard'] = 'two'; 258 | $shell->resetCommandsCalled(); 259 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 260 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 261 | $this->assertTrue($shell->wasCommandCalled("svn cat 'foobar.php'")); 262 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 263 | 264 | // Run a fourth time, restoring the standard, and make sure we do use the cache 265 | $options['standard'] = 'one'; 266 | $shell->resetCommandsCalled(); 267 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 268 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 269 | $this->assertFalse($shell->wasCommandCalled("svn cat 'foobar.php'")); 270 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); 271 | } 272 | 273 | public function testFullSvnWorkflowForOneFileUncachedWhenCachingIsDisabled() { 274 | $svnFile = 'foobar.php'; 275 | $shell = new TestShell([$svnFile]); 276 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 277 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); 278 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 279 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 280 | $options = [ 281 | 'no-cache' => false, // getopt is weird and sets options to false 282 | ]; 283 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 284 | $cache = new TestCache(); 285 | $cache->disabled = true; 286 | $manager = new CacheManager($cache); 287 | runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 288 | $messages = runSvnWorkflow([$svnFile], $options, $shell, $manager, '\LintGuardTests\debug'); 289 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 290 | } 291 | 292 | public function testFullSvnWorkflowForOneFileWithOldCacheVersion() { 293 | $svnFile = 'foobar.php'; 294 | $shell = new TestShell([$svnFile]); 295 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 296 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 297 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 298 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 299 | $options = [ 300 | 'cache' => false, // getopt is weird and sets options to false 301 | ]; 302 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 303 | $cache = new TestCache(); 304 | $cache->setCacheVersion('0.1-something-else'); 305 | $cache->setEntry('foobar.php', 'old', '188280', '', 'blah'); // This invalid JSON will throw if the cache is used 306 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager($cache), '\LintGuardTests\debug'); 307 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 308 | $this->assertTrue($shell->wasCommandCalled("svn cat 'foobar.php'")); 309 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 310 | } 311 | 312 | public function testFullSvnWorkflowForOneFileWithCacheThatHasDifferentStandard() { 313 | $svnFile = 'foobar.php'; 314 | $shell = new TestShell([$svnFile]); 315 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 316 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 317 | $oldFileOutput = $this->phpcs->getResults('STDIN', [20, 99]); 318 | $newFileOutput = $this->phpcs->getResults('STDIN', [20, 21]); 319 | $shell->registerCommand("svn cat 'foobar.php'", $oldFileOutput->toPhpcsJson()); 320 | $shell->registerCommand("cat 'foobar.php'", $newFileOutput->toPhpcsJson()); 321 | $options = [ 322 | 'cache' => false, // getopt is weird and sets options to false 323 | 'standard' => 'TestStandard1', 324 | ]; 325 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 326 | $cache = new TestCache(); 327 | $cache->setEntry('foobar.php', 'old', '188280', 'TestStandard2', 'blah'); // This invalid JSON will throw if the cache is used 328 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager($cache), '\LintGuardTests\debug'); 329 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 330 | $this->assertTrue($shell->wasCommandCalled("svn cat 'foobar.php'")); 331 | $this->assertTrue($shell->wasCommandCalled("cat 'foobar.php'")); 332 | } 333 | 334 | public function testFullSvnWorkflowForOneFileWithCacheOfOldFileVersionDoesNotUseCache() { 335 | $svnFile = 'foobar.php'; 336 | $shell = new TestShell([$svnFile]); 337 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 338 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 339 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 340 | $options = [ 341 | 'cache' => false, // getopt is weird and sets options to false 342 | ]; 343 | $expected = $this->phpcs->getResults('bin/foobar.php', [20]); 344 | $cache = new TestCache(); 345 | 346 | // Set the saved cached revisionId to 1000 347 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280', '1000')); 348 | runSvnWorkflow([$svnFile], $options, $shell, new CacheManager($cache), '\LintGuardTests\debug'); 349 | 350 | // The revisionId of the previous version of the file will be 188000 351 | $shell->deregisterCommand("svn info 'foobar.php'"); 352 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280', '188000')); 353 | $shell->resetCommandsCalled(); 354 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager($cache), '\LintGuardTests\debug'); 355 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 356 | $this->assertTrue($shell->wasCommandCalled("svn cat 'foobar.php'")); 357 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); 358 | } 359 | 360 | public function testFullSvnWorkflowForUnchangedFileWithCache() { 361 | $svnFile = 'foobar.php'; 362 | $shell = new TestShell([$svnFile]); 363 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getEmptyFileDiff()); 364 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php', '188280')); 365 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 366 | $options = [ 367 | 'cache' => false, // getopt is weird and sets options to false 368 | ]; 369 | $expected = PhpcsMessages::fromArrays([], 'bin/foobar.php'); 370 | $cache = new TestCache(); 371 | $cache->setEntry('foobar.php', 'old', '188280', '', $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 372 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager($cache), '\LintGuardTests\debug'); 373 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 374 | $this->assertFalse($shell->wasCommandCalled("svn cat 'foobar.php'")); 375 | $this->assertFalse($shell->wasCommandCalled("cat 'foobar.php'")); 376 | } 377 | 378 | public function testFullSvnWorkflowForMultipleFiles() { 379 | $svnFiles = ['foobar.php', 'baz.php']; 380 | $shell = new TestShell($svnFiles); 381 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getAddedLineDiff('foobar.php', 'use Foobar;')); 382 | $shell->registerCommand("svn diff 'baz.php'", $this->fixture->getAddedLineDiff('baz.php', 'use Baz;')); 383 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); 384 | $shell->registerCommand("svn info 'baz.php'", $this->fixture->getSvnInfo('baz.php')); 385 | 386 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 387 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 388 | $shell->registerCommand("svn cat 'baz.php'", $this->phpcs->getResults('STDIN', [20, 99], 'Found unused symbol Baz.')->toPhpcsJson()); 389 | $shell->registerCommand("cat 'baz.php'", $this->phpcs->getResults('STDIN', [20, 21], 'Found unused symbol Baz.')->toPhpcsJson()); 390 | 391 | $options = []; 392 | $expected = PhpcsMessages::merge([ 393 | $this->phpcs->getResults('bin/foobar.php', [20]), 394 | $this->phpcs->getResults('bin/baz.php', [20], 'Found unused symbol Baz.'), 395 | ]); 396 | $messages = runSvnWorkflow($svnFiles, $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 397 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 398 | } 399 | 400 | public function testFullSvnWorkflowForUnchangedFileWithPhpCsMessages() { 401 | $svnFile = 'foobar.php'; 402 | $shell = new TestShell([$svnFile]); 403 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getEmptyFileDiff()); 404 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); 405 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 406 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 407 | $options = []; 408 | $expected = PhpcsMessages::fromArrays([], 'STDIN'); 409 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 410 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 411 | } 412 | 413 | public function testFullSvnWorkflowForUnchangedFileWithoutPhpCsMessages() { 414 | $svnFile = 'foobar.php'; 415 | $shell = new TestShell([$svnFile]); 416 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getEmptyFileDiff()); 417 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfo('foobar.php')); 418 | $shell->registerCommand("svn cat 'foobar.php'|phpcs", '{"totals":{"errors":0,"warnings":0,"fixable":0},"files":{"STDIN":{"errors":0,"warnings":0,"messages":[]}}}'); 419 | $shell->registerCommand("cat 'foobar.php'|phpcs", '{"totals":{"errors":0,"warnings":0,"fixable":0},"files":{"STDIN":{"errors":0,"warnings":0,"messages":[]}}}'); 420 | $options = []; 421 | $expected = PhpcsMessages::fromArrays([], 'STDIN'); 422 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 423 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 424 | } 425 | 426 | public function testFullSvnWorkflowForNonSvnFile() { 427 | $this->expectException(ShellException::class); 428 | $svnFile = 'foobar.php'; 429 | $shell = new TestShell([$svnFile]); 430 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getNonSvnFileDiff('foobar.php'), 1); 431 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfoNonSvnFile('foobar.php'), 1); 432 | $shell->registerCommand("svn cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 433 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 99])->toPhpcsJson()); 434 | $options = []; 435 | runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 436 | } 437 | 438 | public function testFullSvnWorkflowForNewFile() { 439 | $svnFile = 'foobar.php'; 440 | $shell = new TestShell([$svnFile]); 441 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getNewFileDiff('foobar.php')); 442 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfoNewFile('foobar.php')); 443 | $shell->registerCommand("cat 'foobar.php'", $this->phpcs->getResults('STDIN', [20, 21])->toPhpcsJson()); 444 | $options = []; 445 | $expected = $this->phpcs->getResults('STDIN', [20, 21]); 446 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 447 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 448 | } 449 | 450 | public function testFullSvnWorkflowForEmptyNewFile() { 451 | $svnFile = 'foobar.php'; 452 | $shell = new TestShell([$svnFile]); 453 | $shell->registerCommand("svn diff 'foobar.php'", $this->fixture->getNewFileDiff('foobar.php')); 454 | $shell->registerCommand("svn info 'foobar.php'", $this->fixture->getSvnInfoNewFile('foobar.php')); 455 | $fixture = 'ERROR: You must supply at least one file or directory to process. 456 | 457 | Run "phpcs --help" for usage information 458 | '; 459 | $shell->registerCommand( "cat 'foobar.php'", $fixture); 460 | $options = []; 461 | $expected = PhpcsMessages::fromArrays([], 'STDIN'); 462 | $messages = runSvnWorkflow([$svnFile], $options, $shell, new CacheManager(new TestCache()), '\LintGuardTests\debug'); 463 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 464 | } 465 | } 466 | --------------------------------------------------------------------------------