diff --git a/config.xsd b/config.xsd index 7e505c3e9..935fe464d 100644 --- a/config.xsd +++ b/config.xsd @@ -43,6 +43,7 @@ + diff --git a/psalm.xml.dist b/psalm.xml.dist index 0e8377da7..bfafc4eb9 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -24,6 +24,7 @@ + diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index 0b43adb65..4410041e5 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -301,6 +301,9 @@ class Config */ public $modified_time = 0; + /** @var string|null */ + public $error_baseline = null; + protected function __construct() { self::$instance = $this; @@ -573,6 +576,11 @@ class Config $config->forbid_echo = $attribute_text === 'true' || $attribute_text === '1'; } + if (isset($config_xml['errorBaseline'])) { + $attribute_text = (string) $config_xml['errorBaseline']; + $config->error_baseline = $attribute_text; + } + if (isset($config_xml->projectFiles)) { $config->project_files = ProjectFileFilter::loadFromXMLElement($config_xml->projectFiles, $base_dir, true); } diff --git a/src/Psalm/ErrorBaseline.php b/src/Psalm/ErrorBaseline.php new file mode 100644 index 000000000..e7816cf0a --- /dev/null +++ b/src/Psalm/ErrorBaseline.php @@ -0,0 +1,183 @@ + $issues + * + * @return void + */ + public static function create(FileProvider $fileProvider, string $baselineFile, array $issues) + { + $groupedIssues = self::countIssueTypesByFile($issues); + + self::writeToFile($fileProvider, $baselineFile, $groupedIssues); + } + + /** + * @param FileProvider $fileProvider + * @param string $baselineFile + * @return array> + * @throws Exception\ConfigException + */ + public static function read(FileProvider $fileProvider, string $baselineFile): array + { + if (!$fileProvider->fileExists($baselineFile)) { + throw new Exception\ConfigException("{$baselineFile} does not exist or is not readable\n"); + } + + $xmlSource = $fileProvider->getContents($baselineFile); + + $baselineDoc = new \DOMDocument(); + $baselineDoc->loadXML($xmlSource, LIBXML_NOBLANKS); + + /** @var \DOMNodeList $filesElement */ + $filesElement = $baselineDoc->getElementsByTagName('files'); + + if ($filesElement->length === 0) { + throw new Exception\ConfigException('Baseline file does not contain '); + } + + $files = []; + + /** @var \DOMElement $filesElement */ + $filesElement = $filesElement[0]; + + /** @var \DOMElement $file */ + foreach ($filesElement->getElementsByTagName('file') as $file) { + $fileName = $file->getAttribute('src'); + + $files[$fileName] = []; + + /** @var \DOMElement $issue */ + foreach ($file->childNodes as $issue) { + $issueType = $issue->tagName; + + $files[$fileName][$issueType] = (int)$issue->getAttribute('occurrences'); + } + } + + return $files; + } + + /** + * @param FileProvider $fileProvider + * @param string $baselineFile + * @param array $issues + * @return array> + * @throws Exception\ConfigException + */ + public static function update(FileProvider $fileProvider, string $baselineFile, array $issues) + { + $existingIssues = self::read($fileProvider, $baselineFile); + $newIssues = self::countIssueTypesByFile($issues); + + foreach ($existingIssues as $file => &$existingIssuesCount) { + if (!isset($newIssues[$file])) { + unset($existingIssues[$file]); + + continue; + } + + foreach ($existingIssuesCount as $issueType => $count) { + if (!isset($newIssues[$file][$issueType])) { + unset($existingIssuesCount[$issueType]); + + continue; + } + + $existingIssuesCount[$issueType] = min($count, $newIssues[$file][$issueType]); + } + } + + $groupedIssues = array_filter($existingIssues); + + self::writeToFile($fileProvider, $baselineFile, $groupedIssues); + + return $groupedIssues; + } + + /** + * @param array $issues + * @return array> + */ + private static function countIssueTypesByFile(array $issues): array + { + $groupedIssues = array_reduce( + $issues, + /** + * @param array> $carry + * @param array{type: string, file_name: string, severity: string} $issue + * @return array> + */ + function (array $carry, array $issue): array { + if ($issue['severity'] !== Config::REPORT_ERROR) { + return $carry; + } + + $fileName = $issue['file_name']; + $issueType = $issue['type']; + + if (!isset($carry[$fileName])) { + $carry[$fileName] = []; + } + + if (!isset($carry[$fileName][$issueType])) { + $carry[$fileName][$issueType] = 0; + } + + $carry[$fileName][$issueType]++; + + return $carry; + }, + [] + ); + + // Sort files first + ksort($groupedIssues); + + foreach ($groupedIssues as &$issues) { + ksort($issues); + } + + return $groupedIssues; + } + + /** + * @param FileProvider $fileProvider + * @param string $baselineFile + * @param array> $groupedIssues + * @return void + */ + private static function writeToFile( + FileProvider $fileProvider, + string $baselineFile, + array $groupedIssues + ) { + $baselineDoc = new \DOMDocument('1.0', 'UTF-8'); + $filesNode = $baselineDoc->createElement('files'); + + foreach ($groupedIssues as $file => $issueTypes) { + $fileNode = $baselineDoc->createElement('file'); + $fileNode->setAttribute('src', $file); + + foreach ($issueTypes as $issueType => $count) { + $issueNode = $baselineDoc->createElement($issueType); + $issueNode->setAttribute('occurrences', (string)$count); + $fileNode->appendChild($issueNode); + } + + $filesNode->appendChild($fileNode); + } + + $baselineDoc->appendChild($filesNode); + $baselineDoc->formatOutput = true; + + $fileProvider->setContents($baselineFile, $baselineDoc->saveXML()); + } +} diff --git a/src/Psalm/IssueBuffer.php b/src/Psalm/IssueBuffer.php index 3f3da444e..4bc457203 100644 --- a/src/Psalm/IssueBuffer.php +++ b/src/Psalm/IssueBuffer.php @@ -300,18 +300,20 @@ class IssueBuffer } /** - * @param ProjectChecker $project_checker - * @param bool $is_full - * @param float $start_time - * @param bool $add_stats + * @param ProjectChecker $project_checker + * @param bool $is_full + * @param float $start_time + * @param bool $add_stats + * @param array> $issue_baseline * * @return void */ public static function finish( ProjectChecker $project_checker, - $is_full, - $start_time, - $add_stats = false + bool $is_full, + float $start_time, + bool $add_stats = false, + array $issue_baseline = [] ) { if ($project_checker->output_format === ProjectChecker::TYPE_CONSOLE) { echo "\n"; @@ -341,6 +343,21 @@ class IssueBuffer } ); + if (!empty($issue_baseline)) { + // Set severity for issues in baseline to INFO + foreach (self::$issues_data as $key => $issue_data) { + $file = $issue_data['file_name']; + $type = $issue_data['type']; + + if (isset($issue_baseline[$file][$type]) && $issue_baseline[$file][$type] > 0) { + $issue_data['severity'] = Config::REPORT_INFO; + $issue_baseline[$file][$type] = (int)$issue_baseline[$file][$type] - 1; + } + + self::$issues_data[$key] = $issue_data; + } + } + foreach (self::$issues_data as $issue_data) { if ($issue_data['severity'] === Config::REPORT_ERROR) { ++$error_count; diff --git a/src/psalm.php b/src/psalm.php index 1bb43bb22..fdb9c2a6a 100644 --- a/src/psalm.php +++ b/src/psalm.php @@ -1,6 +1,7 @@ check(); setlocale(LC_CTYPE, 'C'); +if (isset($options['set-baseline'])) { + if (is_array($options['set-baseline'])) { + die('Only one baseline file can be created at a time' . PHP_EOL); + } +} + if (isset($options['i'])) { if (file_exists($current_dir . 'psalm.xml')) { die('A config file already exists in the current directory' . PHP_EOL); @@ -477,4 +496,59 @@ if ($find_references_to) { } } -IssueBuffer::finish($project_checker, !$paths_to_check, $start_time, isset($options['stats'])); +if (isset($options['set-baseline']) && is_string($options['set-baseline'])) { + echo 'Writing error baseline to file...', PHP_EOL; + + ErrorBaseline::create( + new \Psalm\Provider\FileProvider, + $options['set-baseline'], + IssueBuffer::getIssuesData() + ); + + echo "Baseline saved to {$options['set-baseline']}."; + + if (Config::getInstance()->error_baseline !== $options['set-baseline']) { + echo " Don't forget to set errorBaseline=\"{$options['set-baseline']}\" in your config."; + } + + echo PHP_EOL; +} + +$issue_baseline = []; + +if (isset($options['update-baseline'])) { + $baselineFile = Config::getInstance()->error_baseline; + + if (empty($baselineFile)) { + die('Cannot update baseline, because no baseline file is configured.' . PHP_EOL); + } + + try { + $issue_baseline = ErrorBaseline::update( + new \Psalm\Provider\FileProvider, + $baselineFile, + IssueBuffer::getIssuesData() + ); + } catch (\Psalm\Exception\ConfigException $exception) { + die('Could not update baseline file: ' . $exception->getMessage()); + } +} + +if (!empty(Config::getInstance()->error_baseline) && !isset($options['ignore-baseline'])) { + try { + $issue_baseline = ErrorBaseline::read( + new \Psalm\Provider\FileProvider, + (string)Config::getInstance()->error_baseline + ); + } catch (\Psalm\Exception\ConfigException $exception) { + die('Error while reading baseline: ' . $exception->getMessage()); + } +} + +IssueBuffer::finish( + $project_checker, + !$paths_to_check, + $start_time, + isset($options['stats']), + $issue_baseline +); diff --git a/tests/ErrorBaselineTest.php b/tests/ErrorBaselineTest.php new file mode 100644 index 000000000..e3088d30b --- /dev/null +++ b/tests/ErrorBaselineTest.php @@ -0,0 +1,257 @@ +fileProvider = $this->prophesize(FileProvider::class); + } + + /** + * @return void + */ + public function testLoadShouldParseXmlBaselineToPhpArray() + { + $baselineFilePath = 'baseline.xml'; + + $this->fileProvider->fileExists($baselineFilePath)->willReturn(true); + $this->fileProvider->getContents($baselineFilePath)->willReturn( + ' + + + + + + + + + ' + ); + + $expectedParsedBaseline = [ + 'sample/sample-file.php' => [ + 'MixedAssignment' => 2, + 'InvalidReturnStatement' => 1, + ], + 'sample/sample-file2.php' => [ + 'PossiblyUnusedMethod' => 2, + ], + ]; + + $this->assertEquals( + $expectedParsedBaseline, + ErrorBaseline::read($this->fileProvider->reveal(), $baselineFilePath) + ); + } + + /** + * @return void + */ + public function testLoadShouldThrowExceptionWhenFilesAreNotDefinedInBaselineFile() + { + $this->expectException(ConfigException::class); + + $baselineFile = 'baseline.xml'; + + $this->fileProvider->fileExists($baselineFile)->willReturn(true); + $this->fileProvider->getContents($baselineFile)->willReturn( + ' + + + ' + ); + + ErrorBaseline::read($this->fileProvider->reveal(), $baselineFile); + } + + /** + * @return void + */ + public function testLoadShouldThrowExceptionWhenBaselineFileDoesNotExist() + { + $this->expectException(ConfigException::class); + + $baselineFile = 'baseline.xml'; + + $this->fileProvider->fileExists($baselineFile)->willReturn(false); + + ErrorBaseline::read($this->fileProvider->reveal(), $baselineFile); + } + + /** + * @return void + */ + public function testCreateShouldAggregateIssuesPerFile() + { + $baselineFile = 'baseline.xml'; + + $documentContent = null; + + $this->fileProvider->setContents( + $baselineFile, + Argument::that(function (string $document) use (&$documentContent): bool { + $documentContent = $document; + + return true; + }) + )->willReturn(null); + + ErrorBaseline::create($this->fileProvider->reveal(), $baselineFile, [ + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedAssignment', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedAssignment', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedAssignment', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedOperand', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'AssignmentToVoid', + 'severity' => 'info', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'CircularReference', + 'severity' => 'suppress', + ], + [ + 'file_name' => 'sample/sample-file2.php', + 'type' => 'MixedAssignment', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file2.php', + 'type' => 'MixedAssignment', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file2.php', + 'type' => 'TypeCoercion', + 'severity' => 'error', + ], + ]); + + $baselineDocument = new \DOMDocument(); + $baselineDocument->loadXML($documentContent, LIBXML_NOBLANKS); + + /** @var \DOMElement[] $files */ + $files = $baselineDocument->getElementsByTagName('files')[0]->childNodes; + + $file1 = $files[0]; + $file2 = $files[1]; + $this->assertEquals('sample/sample-file.php', $file1->getAttribute('src')); + $this->assertEquals('sample/sample-file2.php', $file2->getAttribute('src')); + + /** @var \DOMElement[] $file1Issues */ + $file1Issues = $file1->childNodes; + /** @var \DOMElement[] $file2Issues */ + $file2Issues = $file2->childNodes; + + $this->assertEquals('MixedAssignment', $file1Issues[0]->tagName); + $this->assertEquals(3, $file1Issues[0]->getAttribute('occurrences')); + $this->assertEquals('MixedOperand', $file1Issues[1]->tagName); + $this->assertEquals(1, $file1Issues[1]->getAttribute('occurrences')); + + $this->assertEquals('MixedAssignment', $file2Issues[0]->tagName); + $this->assertEquals(2, $file2Issues[0]->getAttribute('occurrences')); + $this->assertEquals('TypeCoercion', $file2Issues[1]->tagName); + $this->assertEquals(1, $file2Issues[1]->getAttribute('occurrences')); + } + + /** + * @return void + */ + public function testUpdateShouldRemoveExistingIssuesWithoutAddingNewOnes() + { + $baselineFile = 'baseline.xml'; + + $this->fileProvider->fileExists($baselineFile)->willReturn(true); + $this->fileProvider->getContents($baselineFile)->willReturn( + ' + + + + + + + + + + + + + ' + ); + $this->fileProvider->setContents(Argument::cetera())->willReturn(null); + + $newIssues = [ + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedAssignment', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedAssignment', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedOperand', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file.php', + 'type' => 'MixedOperand', + 'severity' => 'error', + ], + [ + 'file_name' => 'sample/sample-file2.php', + 'type' => 'TypeCoercion', + 'severity' => 'error', + ], + ]; + + $remainingBaseline = ErrorBaseline::update( + $this->fileProvider->reveal(), + $baselineFile, + $newIssues + ); + + $this->assertEquals([ + 'sample/sample-file.php' => [ + 'MixedAssignment' => 2, + 'MixedOperand' => 1, + ], + 'sample/sample-file2.php' => [ + 'TypeCoercion' => 1, + ], + ], $remainingBaseline); + } +}